commit 7ef5348f656e09325990ae3a92ad505898c6ac36 Author: YANG JIANKUAN Date: Thu Feb 26 22:15:35 2026 +0800 init: init proj diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d19ab41 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:ddys.io)", + "Bash(which xcodegen:*)", + "Bash(brew install:*)", + "WebSearch", + "WebFetch(domain:greasyfork.org)", + "WebFetch(domain:github.com)", + "Bash(swift build:*)", + "Bash(sips:*)", + "Bash(xcodegen generate:*)", + "Bash(pwd:*)", + "Bash(open:*)", + "Bash(osascript:*)", + "Bash(curl:*)", + "Bash(xcodebuild:*)", + "Bash(/bin/mkdir:*)", + "Bash(/bin/cp $f:*)", + "Bash(xargs -I{} sample {} 1)", + "Bash(killall xcodebuild:*)", + "Bash(cp:*)", + "Bash(pkill:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9091acf --- /dev/null +++ b/.gitignore @@ -0,0 +1,110 @@ +# Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpackagemanager,swiftpm +# Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,swiftpackagemanager,swiftpm + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### SwiftPackageManager ### +Packages +xcuserdata +*.xcodeproj + + +### SwiftPM ### + + +### Xcode ### + +## Xcode 8 and earlier + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/xcode,swift,swiftpackagemanager,swiftpm diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/DDYSClient/App/ContentView.swift b/DDYSClient/App/ContentView.swift new file mode 100644 index 0000000..c3990d0 --- /dev/null +++ b/DDYSClient/App/ContentView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + AppNavigation() + } +} diff --git a/DDYSClient/App/DDYSClientApp.swift b/DDYSClient/App/DDYSClientApp.swift new file mode 100644 index 0000000..1ca8dbf --- /dev/null +++ b/DDYSClient/App/DDYSClientApp.swift @@ -0,0 +1,36 @@ +import SwiftUI + +@main +struct DDYSClientApp: App { + init() { + CookieManager.shared.applyCookies() + #if os(macOS) + ProcessInfo.processInfo.setValue("低端影视", forKey: "processName") + #endif + } + + var body: some Scene { + WindowGroup { + ContentView() + } + #if os(macOS) + .defaultSize(width: 1100, height: 700) + #endif + + #if os(macOS) + WindowGroup(for: VideoPlayerData.self) { $data in + if let data { + VideoPlayerView( + url: data.url, + title: data.title, + episodeName: data.episodeName, + contentId: data.contentId, + episodeId: data.episodeId + ) + .navigationTitle(data.windowTitle) + } + } + .defaultSize(width: 960, height: 600) + #endif + } +} diff --git a/DDYSClient/DDYSClient.entitlements b/DDYSClient/DDYSClient.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/DDYSClient/DDYSClient.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/DDYSClient/Models/ContentCategory.swift b/DDYSClient/Models/ContentCategory.swift new file mode 100644 index 0000000..66f5fbc --- /dev/null +++ b/DDYSClient/Models/ContentCategory.swift @@ -0,0 +1,32 @@ +import Foundation + +enum ContentCategory: String, CaseIterable, Identifiable, Codable { + case movie = "movie" + case series = "series" + case variety = "variety" + case anime = "anime" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .movie: return "电影" + case .series: return "电视剧" + case .variety: return "综艺" + case .anime: return "动漫" + } + } + + var pathPrefix: String { + "/\(rawValue)" + } + + var icon: String { + switch self { + case .movie: return "film" + case .series: return "tv" + case .variety: return "theatermasks" + case .anime: return "sparkles" + } + } +} diff --git a/DDYSClient/Models/ContentDetail.swift b/DDYSClient/Models/ContentDetail.swift new file mode 100644 index 0000000..1e7ea52 --- /dev/null +++ b/DDYSClient/Models/ContentDetail.swift @@ -0,0 +1,12 @@ +import Foundation + +struct ContentDetail { + let item: ContentItem + let description: String + let directors: [String] + let actors: [String] + let genres: [String] + let region: String + let sources: [StreamSource] + let episodes: [Episode]? +} diff --git a/DDYSClient/Models/ContentItem.swift b/DDYSClient/Models/ContentItem.swift new file mode 100644 index 0000000..f427ebb --- /dev/null +++ b/DDYSClient/Models/ContentItem.swift @@ -0,0 +1,22 @@ +import Foundation + +struct ContentItem: Identifiable, Hashable, Codable { + let id: String // slug 作为 ID + let title: String + let year: Int + let category: ContentCategory + let rating: Double? + let posterURL: URL? + let badges: [String] // 新片/已完结/热门/推荐 + let onlineCount: Int + let netdiskCount: Int + let detailURL: String // 相对路径如 /movie/slug + + static func == (lhs: ContentItem, rhs: ContentItem) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/DDYSClient/Models/Episode.swift b/DDYSClient/Models/Episode.swift new file mode 100644 index 0000000..8975112 --- /dev/null +++ b/DDYSClient/Models/Episode.swift @@ -0,0 +1,7 @@ +import Foundation + +struct Episode: Identifiable, Codable, Hashable { + let id: Int + let name: String // "第01集" + let url: String // m3u8 地址 +} diff --git a/DDYSClient/Models/FilterOption.swift b/DDYSClient/Models/FilterOption.swift new file mode 100644 index 0000000..961f249 --- /dev/null +++ b/DDYSClient/Models/FilterOption.swift @@ -0,0 +1,93 @@ +import Foundation + +struct FilterOption: Identifiable, Hashable { + let id: String + let name: String + let value: String +} + +enum FilterType: String, CaseIterable { + case sort + case genre + case region + case year + + var displayName: String { + switch self { + case .sort: return "排序" + case .genre: return "类型" + case .region: return "地区" + case .year: return "年份" + } + } +} + +struct FilterState { + var sort: String = "" + var genre: String = "" + var region: String = "" + var year: String = "" + + func buildPath(base: String) -> String { + var path = base + if !sort.isEmpty { path = "/\(sort)\(base)" } + if !genre.isEmpty { path += "/genre/\(genre)" } + if !region.isEmpty { path += "/region/\(region)" } + if !year.isEmpty { path += "/year/\(year)" } + return path + } + + static let defaultSorts: [FilterOption] = [ + FilterOption(id: "sort_latest", name: "最新更新", value: ""), + FilterOption(id: "sort_rating", name: "豆瓣评分", value: "rating"), + FilterOption(id: "sort_popular", name: "近期热门", value: "popular"), + ] + + static let defaultGenres: [FilterOption] = [ + FilterOption(id: "genre_all", name: "全部", value: ""), + FilterOption(id: "genre_action", name: "动作", value: "action"), + FilterOption(id: "genre_comedy", name: "喜剧", value: "comedy"), + FilterOption(id: "genre_drama", name: "剧情", value: "drama"), + FilterOption(id: "genre_scifi", name: "科幻", value: "scifi"), + FilterOption(id: "genre_horror", name: "恐怖", value: "horror"), + FilterOption(id: "genre_romance", name: "爱情", value: "romance"), + FilterOption(id: "genre_thriller", name: "惊悚", value: "thriller"), + FilterOption(id: "genre_suspense", name: "悬疑", value: "suspense"), + FilterOption(id: "genre_adventure", name: "冒险", value: "adventure"), + FilterOption(id: "genre_war", name: "战争", value: "war"), + FilterOption(id: "genre_history", name: "历史", value: "history"), + FilterOption(id: "genre_crime", name: "犯罪", value: "crime"), + FilterOption(id: "genre_fantasy", name: "奇幻", value: "fantasy"), + FilterOption(id: "genre_animation", name: "动画", value: "animation"), + FilterOption(id: "genre_documentary", name: "纪录片", value: "documentary"), + FilterOption(id: "genre_family", name: "家庭", value: "family"), + FilterOption(id: "genre_music", name: "音乐", value: "music"), + FilterOption(id: "genre_sport", name: "运动", value: "sport"), + FilterOption(id: "genre_costume", name: "古装", value: "costume"), + FilterOption(id: "genre_martial", name: "武侠", value: "martial"), + ] + + static let defaultRegions: [FilterOption] = [ + FilterOption(id: "region_all", name: "全部", value: ""), + FilterOption(id: "region_usa", name: "美国", value: "usa"), + FilterOption(id: "region_uk", name: "英国", value: "uk"), + FilterOption(id: "region_korea", name: "韩国", value: "korea"), + FilterOption(id: "region_japan", name: "日本", value: "japan"), + FilterOption(id: "region_china", name: "中国大陆", value: "china"), + FilterOption(id: "region_hongkong", name: "中国香港", value: "hongkong"), + FilterOption(id: "region_taiwan", name: "中国台湾", value: "taiwan"), + FilterOption(id: "region_france", name: "法国", value: "france"), + FilterOption(id: "region_germany", name: "德国", value: "germany"), + FilterOption(id: "region_india", name: "印度", value: "india"), + FilterOption(id: "region_thailand", name: "泰国", value: "thailand"), + ] + + static let defaultYears: [FilterOption] = { + var options = [FilterOption(id: "year_all", name: "全部", value: "")] + let currentYear = Calendar.current.component(.year, from: Date()) + for year in stride(from: currentYear, through: 2018, by: -1) { + options.append(FilterOption(id: "year_\(year)", name: "\(year)", value: "\(year)")) + } + return options + }() +} diff --git a/DDYSClient/Models/StreamSource.swift b/DDYSClient/Models/StreamSource.swift new file mode 100644 index 0000000..1d6a1dc --- /dev/null +++ b/DDYSClient/Models/StreamSource.swift @@ -0,0 +1,8 @@ +import Foundation + +struct StreamSource: Identifiable, Codable { + let id: Int + let name: String // "播放源 1" + let quality: String // "1080P" + let episodes: [Episode] +} diff --git a/DDYSClient/Models/VideoPlayerData.swift b/DDYSClient/Models/VideoPlayerData.swift new file mode 100644 index 0000000..0cbf2aa --- /dev/null +++ b/DDYSClient/Models/VideoPlayerData.swift @@ -0,0 +1,18 @@ +import Foundation + +struct VideoPlayerData: Codable, Hashable, Identifiable { + let url: String + let title: String + let episodeName: String? + let contentId: String + let episodeId: Int + + var id: String { "\(contentId)-\(episodeId)" } + + var windowTitle: String { + if let episodeName { + return "\(title) - \(episodeName)" + } + return title + } +} diff --git a/DDYSClient/Services/APIClient.swift b/DDYSClient/Services/APIClient.swift new file mode 100644 index 0000000..144fe9d --- /dev/null +++ b/DDYSClient/Services/APIClient.swift @@ -0,0 +1,121 @@ +import Foundation + +enum APIError: LocalizedError { + case invalidURL + case networkError(Error) + case invalidResponse(Int) + case decodingError(Error) + case noData + + var errorDescription: String? { + switch self { + case .invalidURL: return "无效的 URL" + case .networkError(let error): return "网络错误: \(error.localizedDescription)" + case .invalidResponse(let code): return "服务器错误: \(code)" + case .decodingError(let error): return "解析错误: \(error.localizedDescription)" + case .noData: return "无数据" + } + } +} + +actor APIClient { + static let shared = APIClient() + + private let baseURL = "https://ddys.io" + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.httpAdditionalHeaders = [ + "User-Agent": "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", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Referer": "https://ddys.io/", + ] + config.httpCookieStorage = .shared + self.session = URLSession(configuration: config) + } + + // MARK: - HTML 页面请求 + + func fetchHTML(path: String) async throws -> String { + guard let url = URL(string: baseURL + path) else { + throw APIError.invalidURL + } + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.noData + } + guard (200...299).contains(httpResponse.statusCode) else { + throw APIError.invalidResponse(httpResponse.statusCode) + } + guard let html = String(data: data, encoding: .utf8) else { + throw APIError.noData + } + return html + } + + // MARK: - 首页 + + func fetchHomePage() async throws -> String { + try await fetchHTML(path: "/") + } + + // MARK: - 分类列表 + + func fetchCategoryPage(category: ContentCategory, page: Int = 1, filter: FilterState = FilterState()) async throws -> String { + var path = filter.buildPath(base: category.pathPrefix) + if page > 1 { + path += "/page/\(page)" + } + return try await fetchHTML(path: path) + } + + // MARK: - 详情页 + + func fetchDetailPage(path: String) async throws -> String { + try await fetchHTML(path: path) + } + + // MARK: - 搜索 + + func fetchSearchPage(query: String, page: Int = 1) async throws -> String { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + var path = "/search?q=\(encoded)&type=all" + if page > 1 { + path += "&page=\(page)" + } + return try await fetchHTML(path: path) + } + + // MARK: - JSON API + + func fetchHotMovies() async throws -> [HotMovieItem] { + guard let url = URL(string: baseURL + "/api/hot-movies") else { + throw APIError.invalidURL + } + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw APIError.invalidResponse((response as? HTTPURLResponse)?.statusCode ?? 0) + } + let result = try JSONDecoder().decode(HotMoviesResponse.self, from: data) + return result.data + } +} + +// MARK: - API Response Models + +struct HotMoviesResponse: Codable { + let success: Bool + let data: [HotMovieItem] +} + +struct HotMovieItem: Codable, Identifiable { + let id: Int? + let title: String + let slug: String + let poster: String? + + var itemId: String { slug } +} diff --git a/DDYSClient/Services/ContentCache.swift b/DDYSClient/Services/ContentCache.swift new file mode 100644 index 0000000..138d8e5 --- /dev/null +++ b/DDYSClient/Services/ContentCache.swift @@ -0,0 +1,24 @@ +import Foundation + +@Observable +final class ContentCache { + static let shared = ContentCache() + + private var timestamps: [String: Date] = [:] + private let ttl: TimeInterval = 300 // 5 分钟 + + private init() {} + + func isExpired(key: String) -> Bool { + guard let ts = timestamps[key] else { return true } + return Date().timeIntervalSince(ts) > ttl + } + + func markFresh(key: String) { + timestamps[key] = Date() + } + + func invalidate(key: String) { + timestamps[key] = nil + } +} diff --git a/DDYSClient/Services/CookieManager.swift b/DDYSClient/Services/CookieManager.swift new file mode 100644 index 0000000..631fefc --- /dev/null +++ b/DDYSClient/Services/CookieManager.swift @@ -0,0 +1,64 @@ +import Foundation +import SwiftUI + +@Observable +final class CookieManager { + static let shared = CookieManager() + + private let cookieKey = "ddys_cookie_string" + private let defaults = UserDefaults.standard + + var cookieString: String { + get { defaults.string(forKey: cookieKey) ?? "" } + set { + defaults.set(newValue, forKey: cookieKey) + applyCookies() + } + } + + var isAuthenticated: Bool { + !cookieString.isEmpty + } + + private init() { + applyCookies() + } + + func applyCookies() { + guard !cookieString.isEmpty else { return } + let storage = HTTPCookieStorage.shared + // 清除旧的 ddys cookies + if let cookies = storage.cookies(for: URL(string: "https://ddys.io")!) { + for cookie in cookies { + storage.deleteCookie(cookie) + } + } + // 解析并设置新 cookies + let pairs = cookieString.split(separator: ";").map { $0.trimmingCharacters(in: .whitespaces) } + for pair in pairs { + let parts = pair.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let name = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + let properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: ".ddys.io", + .path: "/", + .secure: "TRUE", + ] + if let cookie = HTTPCookie(properties: properties) { + storage.setCookie(cookie) + } + } + } + + func clearCookies() { + cookieString = "" + if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://ddys.io")!) { + for cookie in cookies { + HTTPCookieStorage.shared.deleteCookie(cookie) + } + } + } +} diff --git a/DDYSClient/Services/HTMLParser.swift b/DDYSClient/Services/HTMLParser.swift new file mode 100644 index 0000000..d4667d3 --- /dev/null +++ b/DDYSClient/Services/HTMLParser.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftSoup + +struct PaginationInfo { + let current: Int + let total: Int +} + +enum HTMLParser { + + // MARK: - 列表页解析 + + static func parseContentList(html: String, defaultCategory: ContentCategory = .movie) throws -> [ContentItem] { + let doc = try SwiftSoup.parse(html) + let cards = try doc.select(".movie-card") + var items: [ContentItem] = [] + + for card in cards { + guard let link = try card.select("a[href^=/movie/]").first() else { continue } + let href = try link.attr("href") + let slug = String(href.replacingOccurrences(of: "/movie/", with: "")) + guard !slug.isEmpty else { continue } + + let title = try card.select("h3 a").text().trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { continue } + + let imgSrc = try card.select("img").attr("src") + let posterURL = URL(string: imgSrc) + + let ratingText = try card.select(".badge-top-right").text().trimmingCharacters(in: .whitespacesAndNewlines) + let rating = Double(ratingText) + + var badges: [String] = [] + let topLeftBadge = try card.select(".badge-top-left").text().trimmingCharacters(in: .whitespacesAndNewlines) + if !topLeftBadge.isEmpty { badges.append(topLeftBadge) } + let bottomRightBadge = try card.select(".badge-bottom-right").text().trimmingCharacters(in: .whitespacesAndNewlines) + if !bottomRightBadge.isEmpty { badges.append(bottomRightBadge) } + + // "2025 · 电影" + let metaDiv = try card.select(".p-4 .text-xs.font-light, .p-4 .text-xs.text-gray-500").first() + let metaText = try metaDiv?.text().trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + var year = 0 + var category = defaultCategory + let metaParts = metaText.split(separator: "·").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if let firstPart = metaParts.first, let y = Int(firstPart) { + year = y + } + if metaParts.count > 1 { + switch metaParts[1] { + case "电影": category = .movie + case "剧集", "电视剧": category = .series + case "综艺": category = .variety + case "动漫", "动画": category = .anime + default: break + } + } + + var onlineCount = 0 + var netdiskCount = 0 + let spans = try card.select(".flex.items-center.gap-3 span") + for span in spans { + let spanText = try span.text() + if spanText.contains("在线") { + onlineCount = Int(spanText.replacingOccurrences(of: "在线:", with: "").trimmingCharacters(in: .whitespaces)) ?? 0 + } else if spanText.contains("网盘") { + netdiskCount = Int(spanText.replacingOccurrences(of: "网盘:", with: "").trimmingCharacters(in: .whitespaces)) ?? 0 + } + } + + items.append(ContentItem( + id: slug, + title: title, + year: year, + category: category, + rating: rating, + posterURL: posterURL, + badges: badges, + onlineCount: onlineCount, + netdiskCount: netdiskCount, + detailURL: href + )) + } + + return items + } + + // MARK: - 详情页解析 + + static func parseContentDetail(html: String) throws -> ContentDetail { + let doc = try SwiftSoup.parse(html) + + // 标题 (h1 可能包含子 span,只取直接文本) + let h1 = try doc.select("h1").first() + let fullTitle = try h1?.text().trimmingCharacters(in: .whitespacesAndNewlines) ?? "未知标题" + + // 海报 + let imgSrc = try doc.select("img.w-full.h-full.object-cover").first()?.attr("src") ?? "" + let posterURL = URL(string: imgSrc) + + // === 优先从 JSON-LD 提取结构化数据 === + var year = 0 + var rating: Double? + var directors: [String] = [] + var actors: [String] = [] + var genres: [String] = [] + var description = "" + var region = "" + + if let jsonLDScript = try doc.select("script[type=application/ld+json]").first() { + let jsonText = try jsonLDScript.data() + if let data = jsonText.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + + if let published = json["datePublished"] as? String { + year = Int(published) ?? 0 + } + + if let agg = json["aggregateRating"] as? [String: Any], + let rv = agg["ratingValue"] as? String { + rating = Double(rv) + } + + if let dirArray = json["director"] as? [[String: Any]] { + directors = dirArray.compactMap { $0["name"] as? String } + } + + if let actArray = json["actor"] as? [[String: Any]] { + actors = actArray.compactMap { $0["name"] as? String } + } + + if let genreArray = json["genre"] as? [String] { + genres = genreArray + } + + if let desc = json["description"] as? String { + description = desc + } + } + } + + // === 从 HTML 补充缺失数据 === + + // 评分 fallback + if rating == nil { + let ratingText = try doc.select(".rating-display").text() + let cleaned = ratingText.components(separatedBy: CharacterSet.decimalDigits.union(CharacterSet(charactersIn: ".")).inverted).joined() + rating = Double(cleaned) + } + + // "2025 · 中国 · 爱情 / 剧情 / 都市" + let metaDivs = try doc.select(".text-xs.text-gray-600.font-light, .text-sm.text-gray-600.font-light") + for metaDiv in metaDivs { + let text = try metaDiv.text().trimmingCharacters(in: .whitespacesAndNewlines) + if text.contains("·") { + let parts = text.split(separator: "·").map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count >= 1, let y = Int(parts[0]), year == 0 { + year = y + } + if parts.count >= 2 && region.isEmpty { + region = parts[1] + } + if parts.count >= 3 && genres.isEmpty { + genres = parts[2].split(separator: "/").map { $0.trimmingCharacters(in: .whitespaces) } + } + break + } + } + + // 导演 fallback: 导演:xxx + if directors.isEmpty { + let dirDivs = try doc.select("div") + for div in dirDivs { + let text = try div.text() + if text.hasPrefix("导演:") || text.hasPrefix("导演:") { + let children = try div.select("span") + if children.size() >= 2 { + let dirText = try children.last()?.text() ?? "" + directors = dirText.split(separator: "/").map { $0.trimmingCharacters(in: .whitespaces) } + } + break + } + } + } + + // 主演 fallback + if actors.isEmpty { + let actDivs = try doc.select("div") + for div in actDivs { + let text = try div.text() + if text.hasPrefix("主演:") || text.hasPrefix("主演:") { + let children = try div.select("span") + if children.size() >= 2 { + let actText = try children.last()?.text() ?? "" + actors = actText.split(separator: "/").map { $0.trimmingCharacters(in: .whitespaces) } + } + break + } + } + } + + // 简介 fallback: .prose p + if description.isEmpty { + let prosePs = try doc.select(".prose p") + let texts = try prosePs.map { try $0.text() } + description = texts.joined(separator: "\n\n") + } + + // === 播放源解析 === + let sources = try parseSourceTabs(doc: doc) + + // slug + let canonicalHref = try doc.select("link[rel=canonical]").attr("href") + let slug: String + if !canonicalHref.isEmpty { + slug = String(canonicalHref.split(separator: "/").last ?? Substring(fullTitle)) + } else { + slug = fullTitle + } + + let contentItem = ContentItem( + id: slug, + title: fullTitle, + year: year, + category: .movie, + rating: rating, + posterURL: posterURL, + badges: [], + onlineCount: 0, + netdiskCount: 0, + detailURL: "/movie/\(slug)" + ) + + // 所有集的合集(取第一个源的) + let firstSourceEpisodes = sources.first?.episodes + + return ContentDetail( + item: contentItem, + description: description, + directors: directors, + actors: actors, + genres: genres, + region: region, + sources: sources, + episodes: (firstSourceEpisodes?.count ?? 0) > 1 ? firstSourceEpisodes : nil + ) + } + + // MARK: - 播放源标签页解析 + + private static func parseSourceTabs(doc: Document) throws -> [StreamSource] { + var sources: [StreamSource] = [] + + // 从按钮的 onclick 属性解析 + let buttons = try doc.select("button[onclick^=switchSource]") + for (index, button) in buttons.enumerated() { + let onclick = try button.attr("onclick") + let name = try button.text().trimmingCharacters(in: .whitespacesAndNewlines) + + guard let parsed = parseSwitchSource(onclick) else { continue } + + let episodes = parseEpisodes(urlString: parsed.url) + let quality = parsed.format == "m3u8" ? "HLS" : parsed.format.uppercased() + + sources.append(StreamSource( + id: parsed.id, + name: name.isEmpty ? "播放源 \(index + 1)" : name, + quality: quality, + episodes: episodes + )) + } + + // fallback: 从 script 中提取 + if sources.isEmpty { + let scripts = try doc.select("script") + for script in scripts { + let content = try script.data() + let pattern = #"switchSource\((\d+),\s*'([^']*)',\s*'([^']*)'\)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } + let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content)) + for (index, match) in matches.enumerated() { + guard match.numberOfRanges >= 4 else { continue } + let idStr = String(content[Range(match.range(at: 1), in: content)!]) + let url = String(content[Range(match.range(at: 2), in: content)!]) + let format = String(content[Range(match.range(at: 3), in: content)!]) + let sourceId = Int(idStr) ?? index + let episodes = parseEpisodes(urlString: url) + sources.append(StreamSource( + id: sourceId, + name: "播放源 \(index + 1)", + quality: format == "m3u8" ? "HLS" : format.uppercased(), + episodes: episodes + )) + } + if !sources.isEmpty { break } + } + } + + return sources + } + + private static func parseSwitchSource(_ onclick: String) -> (id: Int, url: String, format: String)? { + let pattern = #"switchSource\((\d+),\s*'([^']*)',\s*'([^']*)'\)"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: onclick, range: NSRange(onclick.startIndex..., in: onclick)), + match.numberOfRanges >= 4 else { return nil } + let idStr = String(onclick[Range(match.range(at: 1), in: onclick)!]) + let url = String(onclick[Range(match.range(at: 2), in: onclick)!]) + let format = String(onclick[Range(match.range(at: 3), in: onclick)!]) + return (Int(idStr) ?? 0, url, format) + } + + // MARK: - 剧集解析 + + static func parseEpisodes(urlString: String) -> [Episode] { + let parts = urlString.split(separator: "#") + var episodes: [Episode] = [] + + for (index, part) in parts.enumerated() { + let partStr = String(part) + if partStr.contains("$") { + let episodeParts = partStr.split(separator: "$", maxSplits: 1) + if episodeParts.count == 2 { + episodes.append(Episode( + id: index, + name: String(episodeParts[0]), + url: String(episodeParts[1]) + )) + } + } + } + + if episodes.isEmpty && !urlString.isEmpty { + episodes.append(Episode(id: 0, name: "播放", url: urlString)) + } + + return episodes + } + + // MARK: - 分页解析 + + static func parsePagination(html: String) throws -> PaginationInfo { + let doc = try SwiftSoup.parse(html) + + let activeBtn = try doc.select(".pagination-active") + let currentPage = Int(try activeBtn.text().trimmingCharacters(in: .whitespacesAndNewlines)) ?? 1 + + var maxPage = currentPage + let allBtns = try doc.select(".pagination-btn.pagination-number") + for btn in allBtns { + let text = try btn.text().trimmingCharacters(in: .whitespacesAndNewlines) + if let pageNum = Int(text), pageNum > maxPage { + maxPage = pageNum + } + } + + let nextLink = try doc.select(".pagination-next").attr("href") + if let range = nextLink.range(of: #"/page/(\d+)"#, options: .regularExpression) { + let pageStr = nextLink[range].replacingOccurrences(of: "/page/", with: "") + if let p = Int(pageStr), p > maxPage { + maxPage = p + } + } + + return PaginationInfo(current: currentPage, total: maxPage) + } + + // MARK: - 首页区块解析 + + static func parseHomeSections(html: String) throws -> [[ContentItem]] { + let allItems = try parseContentList(html: html) + if allItems.count > 10 { + return [Array(allItems.prefix(10)), Array(allItems.suffix(from: 10))] + } + return [allItems] + } +} diff --git a/DDYSClient/Services/WatchProgressStore.swift b/DDYSClient/Services/WatchProgressStore.swift new file mode 100644 index 0000000..470034f --- /dev/null +++ b/DDYSClient/Services/WatchProgressStore.swift @@ -0,0 +1,69 @@ +import Foundation + +@Observable +final class WatchProgressStore { + static let shared = WatchProgressStore() + + private let defaults = UserDefaults.standard + private let progressKey = "ddys_watch_progress" + + private var progressMap: [String: WatchProgress] { + get { + guard let data = defaults.data(forKey: progressKey), + let map = try? JSONDecoder().decode([String: WatchProgress].self, from: data) else { + return [:] + } + return map + } + set { + if let data = try? JSONEncoder().encode(newValue) { + defaults.set(data, forKey: progressKey) + } + } + } + + private init() {} + + func getProgress(for contentId: String, episodeId: Int = 0) -> WatchProgress? { + let key = "\(contentId)_\(episodeId)" + return progressMap[key] + } + + func saveProgress(contentId: String, episodeId: Int = 0, currentTime: Double, duration: Double) { + let key = "\(contentId)_\(episodeId)" + let progress = WatchProgress( + contentId: contentId, + episodeId: episodeId, + currentTime: currentTime, + duration: duration, + lastWatched: Date() + ) + var map = progressMap + map[key] = progress + progressMap = map + } + + func clearAll() { + defaults.removeObject(forKey: progressKey) + } + + func recentlyWatched(limit: Int = 20) -> [WatchProgress] { + progressMap.values + .sorted { $0.lastWatched > $1.lastWatched } + .prefix(limit) + .map { $0 } + } +} + +struct WatchProgress: Codable { + let contentId: String + let episodeId: Int + let currentTime: Double + let duration: Double + let lastWatched: Date + + var percentage: Double { + guard duration > 0 else { return 0 } + return currentTime / duration + } +} diff --git a/DDYSClient/Utilities/Extensions.swift b/DDYSClient/Utilities/Extensions.swift new file mode 100644 index 0000000..6b39567 --- /dev/null +++ b/DDYSClient/Utilities/Extensions.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftUI + +// MARK: - URL Extensions + +extension URL { + var isHLS: Bool { + pathExtension.lowercased() == "m3u8" || absoluteString.contains(".m3u8") + } +} + +// MARK: - String Extensions + +extension String { + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + func extractNumbers() -> [Int] { + components(separatedBy: CharacterSet.decimalDigits.inverted) + .compactMap { Int($0) } + } +} + +// MARK: - View Extensions + +extension View { + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} diff --git a/DDYSClient/ViewModels/BrowseViewModel.swift b/DDYSClient/ViewModels/BrowseViewModel.swift new file mode 100644 index 0000000..5ed214d --- /dev/null +++ b/DDYSClient/ViewModels/BrowseViewModel.swift @@ -0,0 +1,98 @@ +import Foundation + +@Observable +final class BrowseViewModel { + var items: [ContentItem] = [] + var category: ContentCategory = .movie + var filter = FilterState() + var currentPage = 1 + var totalPages = 1 + var isLoading = false + var isLoadingMore = false + var error: String? + + private var cacheKey: String { + "\(category.rawValue)_\(filter.sort)_\(filter.genre)_\(filter.region)_\(filter.year)" + } + + var hasData: Bool { + !items.isEmpty + } + + @MainActor + func loadContentIfNeeded() async { + if hasData && !ContentCache.shared.isExpired(key: cacheKey) { + return + } + await loadContent() + } + + @MainActor + func loadContent() async { + isLoading = true + error = nil + currentPage = 1 + totalPages = 1 + items = [] + + do { + let html = try await APIClient.shared.fetchCategoryPage( + category: category, + page: 1, + filter: filter + ) + let newItems = try HTMLParser.parseContentList(html: html, defaultCategory: category) + let pagination = try HTMLParser.parsePagination(html: html) + + self.items = newItems + self.currentPage = pagination.current + self.totalPages = pagination.total + + ContentCache.shared.markFresh(key: cacheKey) + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + @MainActor + func loadMore() async { + guard !isLoadingMore, !isLoading, currentPage < totalPages else { return } + isLoadingMore = true + + let nextPage = currentPage + 1 + do { + let html = try await APIClient.shared.fetchCategoryPage( + category: category, + page: nextPage, + filter: filter + ) + let newItems = try HTMLParser.parseContentList(html: html, defaultCategory: category) + let pagination = try HTMLParser.parsePagination(html: html) + + self.items.append(contentsOf: newItems) + self.currentPage = pagination.current + self.totalPages = pagination.total + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + // 加载更多失败不显示全局错误,允许重试 + } + + isLoadingMore = false + } + + func changeCategory(_ newCategory: ContentCategory) async { + category = newCategory + filter = FilterState() + await loadContentIfNeeded() + } + + func applyFilter(_ newFilter: FilterState) async { + filter = newFilter + await loadContent() + } +} diff --git a/DDYSClient/ViewModels/DetailViewModel.swift b/DDYSClient/ViewModels/DetailViewModel.swift new file mode 100644 index 0000000..0e228fb --- /dev/null +++ b/DDYSClient/ViewModels/DetailViewModel.swift @@ -0,0 +1,66 @@ +import Foundation + +@Observable +final class DetailViewModel { + var detail: ContentDetail? + var selectedSourceIndex = 0 + var selectedEpisodeIndex = 0 + var isLoading = false + var error: String? + + var currentSource: StreamSource? { + guard let detail, selectedSourceIndex < detail.sources.count else { return nil } + return detail.sources[selectedSourceIndex] + } + + var currentEpisode: Episode? { + guard let source = currentSource, + selectedEpisodeIndex < source.episodes.count else { return nil } + return source.episodes[selectedEpisodeIndex] + } + + var hasMultipleEpisodes: Bool { + guard let source = currentSource else { return false } + return source.episodes.count > 1 + } + + func loadDetail(path: String) async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let html = try await APIClient.shared.fetchDetailPage(path: path) + let parsedDetail = try HTMLParser.parseContentDetail(html: html) + await MainActor.run { + self.detail = parsedDetail + self.selectedSourceIndex = 0 + self.selectedEpisodeIndex = 0 + } + } catch is CancellationError { + return + } catch let error as URLError where error.code == .cancelled { + return + } catch { + await MainActor.run { self.error = error.localizedDescription } + } + } + + func selectSource(_ index: Int) { + selectedSourceIndex = index + selectedEpisodeIndex = 0 + } + + func selectEpisode(_ index: Int) { + selectedEpisodeIndex = index + } + + func nextEpisode() -> Bool { + guard let source = currentSource else { return false } + if selectedEpisodeIndex + 1 < source.episodes.count { + selectedEpisodeIndex += 1 + return true + } + return false + } +} diff --git a/DDYSClient/ViewModels/HomeViewModel.swift b/DDYSClient/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..40c0b7b --- /dev/null +++ b/DDYSClient/ViewModels/HomeViewModel.swift @@ -0,0 +1,65 @@ +import Foundation + +@Observable +final class HomeViewModel { + var recommendedItems: [ContentItem] = [] + var latestItems: [ContentItem] = [] + var hotMovies: [HotMovieItem] = [] + var isLoading = false + var error: String? + + private let cacheKey = "home" + + var hasData: Bool { + !recommendedItems.isEmpty || !latestItems.isEmpty + } + + @MainActor + func loadHomeIfNeeded() async { + if hasData && !ContentCache.shared.isExpired(key: cacheKey) { + return + } + await loadHome() + } + + @MainActor + func loadHome() async { + isLoading = true + error = nil + + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.loadHomePage() } + group.addTask { await self.loadHotMovies() } + } + + if error == nil && hasData { + ContentCache.shared.markFresh(key: cacheKey) + } + + isLoading = false + } + + private func loadHomePage() async { + do { + let html = try await APIClient.shared.fetchHomePage() + let sections = try HTMLParser.parseHomeSections(html: html) + await MainActor.run { + if sections.count > 0 { self.recommendedItems = sections[0] } + if sections.count > 1 { self.latestItems = sections[1] } + } + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + await MainActor.run { self.error = error.localizedDescription } + } + } + + private func loadHotMovies() async { + do { + let movies = try await APIClient.shared.fetchHotMovies() + await MainActor.run { self.hotMovies = movies } + } catch { + // 热门 API 失败不影响主页显示 + } + } +} diff --git a/DDYSClient/ViewModels/PlayerViewModel.swift b/DDYSClient/ViewModels/PlayerViewModel.swift new file mode 100644 index 0000000..90d1105 --- /dev/null +++ b/DDYSClient/ViewModels/PlayerViewModel.swift @@ -0,0 +1,134 @@ +import AVFoundation +import Foundation + +@Observable +final class PlayerViewModel { + var player: AVPlayer? + var isPlaying = false + var currentTime: Double = 0 + var duration: Double = 0 + var isLoading = false + var error: String? + var playbackRate: Float = 1.0 + + private var timeObserver: Any? + private var contentId: String = "" + private var episodeId: Int = 0 + + var progress: Double { + guard duration > 0 else { return 0 } + return currentTime / duration + } + + var currentTimeText: String { + formatTime(currentTime) + } + + var durationText: String { + formatTime(duration) + } + + func play(url: String, contentId: String, episodeId: Int = 0) { + self.contentId = contentId + self.episodeId = episodeId + + guard let videoURL = URL(string: url) else { + error = "无效的视频地址" + return + } + + let playerItem = AVPlayerItem(url: videoURL) + player = AVPlayer(playerItem: playerItem) + player?.rate = playbackRate + + // 恢复上次进度 + if let savedProgress = WatchProgressStore.shared.getProgress(for: contentId, episodeId: episodeId) { + let resumeTime = CMTime(seconds: savedProgress.currentTime, preferredTimescale: 600) + player?.seek(to: resumeTime) + } + + setupTimeObserver() + player?.play() + isPlaying = true + } + + func togglePlay() { + guard let player else { return } + if isPlaying { + player.pause() + } else { + player.play() + } + isPlaying.toggle() + } + + func seek(to time: Double) { + let cmTime = CMTime(seconds: time, preferredTimescale: 600) + player?.seek(to: cmTime) + } + + func seekForward(_ seconds: Double = 10) { + seek(to: min(currentTime + seconds, duration)) + } + + func seekBackward(_ seconds: Double = 10) { + seek(to: max(currentTime - seconds, 0)) + } + + func setRate(_ rate: Float) { + playbackRate = rate + player?.rate = rate + } + + func stop() { + saveProgress() + player?.pause() + if let observer = timeObserver { + player?.removeTimeObserver(observer) + } + timeObserver = nil + player = nil + isPlaying = false + } + + private func setupTimeObserver() { + let interval = CMTime(seconds: 1, preferredTimescale: 600) + timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self else { return } + self.currentTime = time.seconds + if let item = self.player?.currentItem { + self.duration = item.duration.seconds.isNaN ? 0 : item.duration.seconds + } + // 每 10 秒保存一次进度 + if Int(self.currentTime) % 10 == 0 { + self.saveProgress() + } + } + } + + private func saveProgress() { + guard duration > 0 else { return } + WatchProgressStore.shared.saveProgress( + contentId: contentId, + episodeId: episodeId, + currentTime: currentTime, + duration: duration + ) + } + + private func formatTime(_ seconds: Double) -> String { + guard !seconds.isNaN, seconds >= 0 else { return "00:00" } + let totalSeconds = Int(seconds) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let secs = totalSeconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%02d:%02d", minutes, secs) + } + + deinit { + stop() + } +} diff --git a/DDYSClient/ViewModels/SearchViewModel.swift b/DDYSClient/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..b214893 --- /dev/null +++ b/DDYSClient/ViewModels/SearchViewModel.swift @@ -0,0 +1,75 @@ +import Foundation + +@Observable +final class SearchViewModel { + var query = "" + var results: [ContentItem] = [] + var isSearching = false + var isLoadingMore = false + var currentPage = 1 + var totalPages = 1 + var error: String? + var hasSearched = false + + @MainActor + func search() async { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + isSearching = true + error = nil + hasSearched = true + currentPage = 1 + totalPages = 1 + results = [] + + do { + let html = try await APIClient.shared.fetchSearchPage(query: trimmed) + let items = try HTMLParser.parseContentList(html: html) + let pagination = try HTMLParser.parsePagination(html: html) + + self.results = items + self.currentPage = pagination.current + self.totalPages = pagination.total + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + self.error = error.localizedDescription + } + + isSearching = false + } + + @MainActor + func loadMore() async { + guard !isSearching, !isLoadingMore, currentPage < totalPages else { return } + isLoadingMore = true + + let nextPage = currentPage + 1 + do { + let html = try await APIClient.shared.fetchSearchPage(query: query, page: nextPage) + let items = try HTMLParser.parseContentList(html: html) + let pagination = try HTMLParser.parsePagination(html: html) + + self.results.append(contentsOf: items) + self.currentPage = pagination.current + self.totalPages = pagination.total + } catch is CancellationError { + } catch let error as URLError where error.code == .cancelled { + } catch { + // 静默处理 + } + + isLoadingMore = false + } + + @MainActor + func clear() { + query = "" + results = [] + hasSearched = false + error = nil + currentPage = 1 + totalPages = 1 + } +} diff --git a/DDYSClient/Views/Auth/CookieInputView.swift b/DDYSClient/Views/Auth/CookieInputView.swift new file mode 100644 index 0000000..1c58685 --- /dev/null +++ b/DDYSClient/Views/Auth/CookieInputView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct CookieInputView: View { + @Environment(\.dismiss) private var dismiss + @State private var cookieText = "" + + var body: some View { + NavigationStack { + Form { + Section { + Text("请从浏览器中复制 ddys.io 的 Cookie 字符串并粘贴到下方。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Section("Cookie") { + TextEditor(text: $cookieText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 120) + } + + Section { + Text("获取方法:打开浏览器开发者工具 → Network → 任意请求 → Headers → Cookie") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .navigationTitle("输入 Cookie") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + let trimmed = cookieText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + CookieManager.shared.cookieString = trimmed + } + dismiss() + } + .disabled(cookieText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} diff --git a/DDYSClient/Views/Auth/WebLoginView.swift b/DDYSClient/Views/Auth/WebLoginView.swift new file mode 100644 index 0000000..09a9ba0 --- /dev/null +++ b/DDYSClient/Views/Auth/WebLoginView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +#if canImport(WebKit) +import WebKit + +struct WebLoginView: View { + @Environment(\.dismiss) private var dismiss + @State private var isLoading = true + + var body: some View { + NavigationStack { + ZStack { + WebLoginRepresentable( + url: URL(string: "https://ddys.io")!, + isLoading: $isLoading, + onCookiesExtracted: { cookies in + CookieManager.shared.cookieString = cookies + dismiss() + } + ) + + if isLoading { + ProgressView("加载中...") + } + } + .navigationTitle("登录 DDYS") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("提取 Cookie") { + // 通知 WebView 提取 cookies + NotificationCenter.default.post(name: .extractCookies, object: nil) + } + } + } + } + } +} + +extension Notification.Name { + static let extractCookies = Notification.Name("extractCookies") +} + +#if os(iOS) +struct WebLoginRepresentable: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + let onCookiesExtracted: (String) -> Void + + func makeUIView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + webView.load(URLRequest(url: url)) + + NotificationCenter.default.addObserver( + context.coordinator, + selector: #selector(Coordinator.extractCookies), + name: .extractCookies, + object: nil + ) + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, WKNavigationDelegate { + let parent: WebLoginRepresentable + weak var webView: WKWebView? + + init(parent: WebLoginRepresentable) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + self.webView = webView + parent.isLoading = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + parent.isLoading = false + } + + @objc func extractCookies() { + guard let webView else { return } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + let ddysCookies = cookies + .filter { $0.domain.contains("ddys") } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + + DispatchQueue.main.async { + self.parent.onCookiesExtracted(ddysCookies) + } + } + } + } +} +#else +struct WebLoginRepresentable: NSViewRepresentable { + let url: URL + @Binding var isLoading: Bool + let onCookiesExtracted: (String) -> Void + + func makeNSView(context: Context) -> WKWebView { + let config = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = context.coordinator + webView.load(URLRequest(url: url)) + + NotificationCenter.default.addObserver( + context.coordinator, + selector: #selector(Coordinator.extractCookies), + name: .extractCookies, + object: nil + ) + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + class Coordinator: NSObject, WKNavigationDelegate { + let parent: WebLoginRepresentable + weak var webView: WKWebView? + + init(parent: WebLoginRepresentable) { + self.parent = parent + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + self.webView = webView + parent.isLoading = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + parent.isLoading = false + } + + @objc func extractCookies() { + guard let webView else { return } + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + let ddysCookies = cookies + .filter { $0.domain.contains("ddys") } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + + DispatchQueue.main.async { + self.parent.onCookiesExtracted(ddysCookies) + } + } + } + } +} +#endif +#endif diff --git a/DDYSClient/Views/Browse/BrowseView.swift b/DDYSClient/Views/Browse/BrowseView.swift new file mode 100644 index 0000000..b5cb7a8 --- /dev/null +++ b/DDYSClient/Views/Browse/BrowseView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct BrowseView: View { + let category: ContentCategory + @Bindable var viewModel: BrowseViewModel + + var body: some View { + Group { + if !viewModel.hasData && viewModel.error == nil { + VStack(spacing: 0) { + FilterBarView(filter: $viewModel.filter) { + Task { await viewModel.applyFilter(viewModel.filter) } + } + .padding(.vertical, 8) + + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else { + ScrollView { + VStack(spacing: 0) { + FilterBarView(filter: $viewModel.filter) { + Task { await viewModel.applyFilter(viewModel.filter) } + } + .padding(.vertical, 8) + + if let error = viewModel.error { + errorView(error) + } else if viewModel.items.isEmpty { + emptyView + } else { + ContentGridView(items: viewModel.items) { + Task { await viewModel.loadMore() } + } + .padding(.horizontal) + .padding(.top, 8) + + if viewModel.isLoadingMore { + ProgressView() + .padding() + } + } + } + } + } + } + .navigationTitle(category.displayName) + .navigationDestination(for: ContentItem.self) { item in + DetailView(item: item) + } + .refreshable { + await viewModel.loadContent() + } + .task { + viewModel.category = category + await viewModel.loadContentIfNeeded() + } + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .foregroundStyle(.secondary) + Button("重试") { + Task { await viewModel.loadContent() } + } + } + .frame(maxWidth: .infinity, minHeight: 300) + } + + private var emptyView: some View { + VStack(spacing: 12) { + Image(systemName: "film.stack") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("暂无内容") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } +} diff --git a/DDYSClient/Views/Browse/ContentCardView.swift b/DDYSClient/Views/Browse/ContentCardView.swift new file mode 100644 index 0000000..7c4e498 --- /dev/null +++ b/DDYSClient/Views/Browse/ContentCardView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct ContentCardView: View { + let item: ContentItem + + private let cardWidth: CGFloat = 140 + private let aspectRatio: CGFloat = 2.0 / 3.0 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // 海报 + ZStack(alignment: .topLeading) { + posterImage + .frame(width: cardWidth, height: cardWidth / aspectRatio) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // 评分角标 + if let rating = item.rating, rating > 0 { + Text(String(format: "%.1f", rating)) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.orange.gradient, in: RoundedRectangle(cornerRadius: 4)) + .padding(6) + .frame(maxWidth: .infinity, alignment: .topTrailing) + } + + // 状态徽章 + if let badge = item.badges.first { + Text(badge) + .font(.caption2) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.blue.gradient, in: RoundedRectangle(cornerRadius: 4)) + .padding(6) + } + } + + // 标题 + Text(item.title) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .frame(width: cardWidth, alignment: .leading) + + // 年份 + if item.year > 0 { + Text("\(String(item.year))") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .frame(width: cardWidth) + } + + @ViewBuilder + private var posterImage: some View { + CachedAsyncImage(url: item.posterURL) { + posterPlaceholder + } + } + + private var posterPlaceholder: some View { + Rectangle() + .fill(.quaternary) + .overlay { + Image(systemName: "film") + .font(.title) + .foregroundStyle(.tertiary) + } + } +} diff --git a/DDYSClient/Views/Browse/ContentGridView.swift b/DDYSClient/Views/Browse/ContentGridView.swift new file mode 100644 index 0000000..255c106 --- /dev/null +++ b/DDYSClient/Views/Browse/ContentGridView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct ContentGridView: View { + let items: [ContentItem] + var onNearEnd: (() -> Void)? + + private let columns = [ + GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(items) { item in + NavigationLink(value: item) { + ContentCardView(item: item) + } + .buttonStyle(.plain) + .onAppear { + if let onNearEnd, isNearEnd(item) { + onNearEnd() + } + } + } + } + } + + private func isNearEnd(_ item: ContentItem) -> Bool { + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return false } + return index >= items.count - 4 + } +} diff --git a/DDYSClient/Views/Browse/FilterBarView.swift b/DDYSClient/Views/Browse/FilterBarView.swift new file mode 100644 index 0000000..94b6c47 --- /dev/null +++ b/DDYSClient/Views/Browse/FilterBarView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct FilterBarView: View { + @Binding var filter: FilterState + var onFilterChanged: () -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort) + filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre) + filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region) + filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year) + } + .padding(.horizontal) + } + } + + private func filterMenu(_ title: String, options: [FilterOption], selection: Binding) -> some View { + Menu { + ForEach(options) { option in + Button { + selection.wrappedValue = option.value + onFilterChanged() + } label: { + HStack { + Text(option.name) + if selection.wrappedValue == option.value { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(selectedName(for: selection.wrappedValue, in: options) ?? title) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + selection.wrappedValue.isEmpty + ? Color.secondary.opacity(0.12) + : Color.accentColor.opacity(0.15), + in: Capsule() + ) + .foregroundStyle(selection.wrappedValue.isEmpty ? Color.primary : Color.accentColor) + } + } + + private func selectedName(for value: String, in options: [FilterOption]) -> String? { + guard !value.isEmpty else { return nil } + return options.first { $0.value == value }?.name + } +} diff --git a/DDYSClient/Views/Common/CachedAsyncImage.swift b/DDYSClient/Views/Common/CachedAsyncImage.swift new file mode 100644 index 0000000..c4030da --- /dev/null +++ b/DDYSClient/Views/Common/CachedAsyncImage.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct CachedAsyncImage: View { + let url: URL? + @ViewBuilder let placeholder: () -> Placeholder + + @State private var image: Image? + @State private var isLoading = false + + var body: some View { + Group { + if let image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + placeholder() + .task(id: url) { + await loadImage() + } + } + } + } + + private func loadImage() async { + guard let url, !isLoading else { return } + + // 检查内存缓存 + if let cached = ImageCache.shared.get(url) { + self.image = cached + return + } + + isLoading = true + defer { isLoading = false } + + do { + let (data, _) = try await ImageCache.shared.session.data(from: url) + #if os(macOS) + if let nsImage = NSImage(data: data) { + let img = Image(nsImage: nsImage) + ImageCache.shared.set(img, for: url) + self.image = img + } + #else + if let uiImage = UIImage(data: data) { + let img = Image(uiImage: uiImage) + ImageCache.shared.set(img, for: url) + self.image = img + } + #endif + } catch { + // 加载失败保持 placeholder + } + } +} + +// MARK: - 图片内存缓存 + +private final class ImageCache: @unchecked Sendable { + static let shared = ImageCache() + + private let cache = NSCache() + let session: URLSession + + private init() { + cache.countLimit = 200 + cache.totalCostLimit = 100 * 1024 * 1024 // 100MB + + // 配置磁盘缓存 + let config = URLSessionConfiguration.default + config.urlCache = URLCache( + memoryCapacity: 50 * 1024 * 1024, // 50MB 内存 + diskCapacity: 200 * 1024 * 1024 // 200MB 磁盘 + ) + config.requestCachePolicy = .returnCacheDataElseLoad + session = URLSession(configuration: config) + } + + func get(_ url: URL) -> Image? { + cache.object(forKey: url as NSURL)?.image + } + + func set(_ image: Image, for url: URL) { + cache.setObject(CacheEntry(image: image), forKey: url as NSURL) + } +} + +private final class CacheEntry { + let image: Image + init(image: Image) { self.image = image } +} diff --git a/DDYSClient/Views/Detail/DetailView.swift b/DDYSClient/Views/Detail/DetailView.swift new file mode 100644 index 0000000..aa48d04 --- /dev/null +++ b/DDYSClient/Views/Detail/DetailView.swift @@ -0,0 +1,289 @@ +import SwiftUI + +struct DetailView: View { + let item: ContentItem + @State private var viewModel = DetailViewModel() + + #if os(macOS) + @Environment(\.openWindow) private var openWindow + #else + @State private var showPlayer = false + #endif + + var body: some View { + Group { + if viewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.error { + errorView(error) + } else if let detail = viewModel.detail { + ScrollView { + detailContent(detail) + } + } + } + .navigationTitle(item.title) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { + if viewModel.detail == nil { + await viewModel.loadDetail(path: item.detailURL) + } + } + #if os(iOS) + .sheet(isPresented: $showPlayer) { + if let episode = viewModel.currentEpisode { + VideoPlayerView( + url: episode.url, + title: item.title, + episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil, + contentId: item.id, + episodeId: episode.id + ) + } + } + #endif + } + + private func playVideo() { + guard let episode = viewModel.currentEpisode else { return } + #if os(macOS) + let data = VideoPlayerData( + url: episode.url, + title: item.title, + episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil, + contentId: item.id, + episodeId: episode.id + ) + openWindow(value: data) + #else + showPlayer = true + #endif + } + + @ViewBuilder + private func detailContent(_ detail: ContentDetail) -> some View { + VStack(alignment: .leading, spacing: 20) { + // 顶部:海报 + 元信息 + HStack(alignment: .top, spacing: 16) { + posterImage(detail) + .frame(width: 160, height: 240) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(radius: 4) + + VStack(alignment: .leading, spacing: 8) { + Text(detail.item.title) + .font(.title2) + .fontWeight(.bold) + .lineLimit(3) + + // 评分 + if let rating = detail.item.rating, rating > 0 { + HStack(spacing: 4) { + Image(systemName: "star.fill") + .foregroundStyle(.orange) + Text(String(format: "%.1f", rating)) + .fontWeight(.semibold) + } + } + + // 年份 · 地区 + if detail.item.year > 0 || !detail.region.isEmpty { + let parts = [ + detail.item.year > 0 ? "\(detail.item.year)" : nil, + detail.region.isEmpty ? nil : detail.region, + ].compactMap { $0 } + Text(parts.joined(separator: " · ")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + // 类型标签 + if !detail.genres.isEmpty { + FlowLayout(spacing: 6) { + ForEach(detail.genres, id: \.self) { genre in + Text(genre) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.secondary.opacity(0.12), in: Capsule()) + } + } + } + + Spacer(minLength: 0) + + // 播放按钮 + if !detail.sources.isEmpty { + Button { + playVideo() + } label: { + Label("立即播放", systemImage: "play.fill") + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + } + } + .padding(.horizontal) + + // 播放源选择 + if detail.sources.count > 1 { + VStack(alignment: .leading, spacing: 8) { + Text("播放源") + .font(.headline) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(detail.sources.enumerated()), id: \.element.id) { index, source in + Button { + viewModel.selectSource(index) + } label: { + Text(source.name) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + viewModel.selectedSourceIndex == index + ? Color.accentColor + : Color.secondary.opacity(0.12), + in: Capsule() + ) + .foregroundStyle( + viewModel.selectedSourceIndex == index ? .white : .primary + ) + } + .buttonStyle(.plain) + } + } + } + } + .padding(.horizontal) + } + + // 剧集选择(点击直接播放) + if viewModel.hasMultipleEpisodes, let source = viewModel.currentSource { + EpisodeListView( + episodes: source.episodes, + selectedIndex: viewModel.selectedEpisodeIndex + ) { index in + viewModel.selectEpisode(index) + playVideo() + } + .padding(.horizontal) + } + + Divider().padding(.horizontal) + + // 简介 + if !detail.description.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("剧情简介") + .font(.headline) + Text(detail.description) + .font(.body) + .foregroundStyle(.secondary) + .lineSpacing(4) + } + .padding(.horizontal) + } + + // 导演 + if !detail.directors.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("导演") + .font(.headline) + Text(detail.directors.joined(separator: " / ")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + // 主演 + if !detail.actors.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("主演") + .font(.headline) + Text(detail.actors.joined(separator: " / ")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + Spacer(minLength: 32) + } + .padding(.vertical) + } + + @ViewBuilder + private func posterImage(_ detail: ContentDetail) -> some View { + CachedAsyncImage(url: detail.item.posterURL) { + Rectangle() + .fill(.quaternary) + .overlay { + Image(systemName: "film") + .font(.largeTitle) + .foregroundStyle(.tertiary) + } + } + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .foregroundStyle(.secondary) + Button("重试") { + Task { await viewModel.loadDetail(path: item.detailURL) } + } + } + .frame(maxWidth: .infinity, minHeight: 400) + } +} + +// MARK: - FlowLayout for genre tags + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = arrange(proposal: proposal, subviews: subviews) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(proposal: proposal, 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 arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) { + let maxWidth = proposal.width ?? .infinity + var positions: [CGPoint] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var maxX: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x + size.width > maxWidth && x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + positions.append(CGPoint(x: x, y: y)) + rowHeight = max(rowHeight, size.height) + x += size.width + spacing + maxX = max(maxX, x) + } + + return (CGSize(width: maxX, height: y + rowHeight), positions) + } +} diff --git a/DDYSClient/Views/Detail/EpisodeListView.swift b/DDYSClient/Views/Detail/EpisodeListView.swift new file mode 100644 index 0000000..1cfb3e7 --- /dev/null +++ b/DDYSClient/Views/Detail/EpisodeListView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct EpisodeListView: View { + let episodes: [Episode] + let selectedIndex: Int + let onSelect: (Int) -> Void + + private let columns = [ + GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8) + ] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("选集 (\(episodes.count)集)") + .font(.headline) + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(Array(episodes.enumerated()), id: \.element.id) { index, episode in + Button { + onSelect(index) + } label: { + Text(episode.name) + .font(.caption) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + selectedIndex == index + ? Color.accentColor + : Color.secondary.opacity(0.12), + in: RoundedRectangle(cornerRadius: 6) + ) + .foregroundStyle(selectedIndex == index ? .white : .primary) + } + .buttonStyle(.plain) + } + } + } + } +} diff --git a/DDYSClient/Views/Home/HomeView.swift b/DDYSClient/Views/Home/HomeView.swift new file mode 100644 index 0000000..cfad7dc --- /dev/null +++ b/DDYSClient/Views/Home/HomeView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct HomeView: View { + var viewModel: HomeViewModel + @State private var searchText = "" + @State private var searchViewModel = SearchViewModel() + + private var showingSearchResults: Bool { + searchViewModel.hasSearched + } + + var body: some View { + Group { + if !viewModel.hasData && viewModel.error == nil && !showingSearchResults { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if showingSearchResults && searchViewModel.isSearching { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + if showingSearchResults { + searchContent + } else { + homeContent + } + } + } + } + .navigationTitle("低端影视") + .navigationDestination(for: ContentItem.self) { item in + DetailView(item: item) + } + .searchable(text: $searchText, prompt: "搜索电影、电视剧...") + .onSubmit(of: .search) { + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + Task { + searchViewModel.query = trimmed + await searchViewModel.search() + } + } + .onChange(of: searchText) { _, newValue in + if newValue.isEmpty { + searchViewModel.clear() + } + } + .refreshable { + if showingSearchResults { + await searchViewModel.search() + } else { + await viewModel.loadHome() + } + } + .task { + await viewModel.loadHomeIfNeeded() + } + } + + // MARK: - 首页内容 + + @ViewBuilder + private var homeContent: some View { + if let error = viewModel.error { + errorView(error) + } else { + LazyVStack(alignment: .leading, spacing: 24) { + if !viewModel.recommendedItems.isEmpty { + sectionHeader("热门推荐") + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 12) { + ForEach(viewModel.recommendedItems) { item in + NavigationLink(value: item) { + ContentCardView(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + + if !viewModel.latestItems.isEmpty { + sectionHeader("最新更新") + ContentGridView(items: viewModel.latestItems) + .padding(.horizontal) + } + } + .padding(.vertical) + } + } + + // MARK: - 搜索结果 + + @ViewBuilder + private var searchContent: some View { + if let error = searchViewModel.error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(error) + .foregroundStyle(.secondary) + Button("重试") { + Task { await searchViewModel.search() } + } + } + .frame(maxWidth: .infinity, minHeight: 300) + } else if searchViewModel.results.isEmpty { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("未找到「\(searchViewModel.query)」的相关内容") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } else { + LazyVStack(alignment: .leading, spacing: 16) { + Text("搜索结果") + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal) + + ContentGridView(items: searchViewModel.results) { + Task { await searchViewModel.loadMore() } + } + .padding(.horizontal) + + if searchViewModel.isLoadingMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } + } + .padding(.vertical) + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(message) + .foregroundStyle(.secondary) + Button("重试") { + Task { await viewModel.loadHome() } + } + } + .frame(maxWidth: .infinity, minHeight: 300) + } +} diff --git a/DDYSClient/Views/Navigation/AppNavigation.swift b/DDYSClient/Views/Navigation/AppNavigation.swift new file mode 100644 index 0000000..01b391e --- /dev/null +++ b/DDYSClient/Views/Navigation/AppNavigation.swift @@ -0,0 +1,114 @@ +import SwiftUI + +enum AppTab: String, CaseIterable, Identifiable { + case home + case movie + case series + case variety + case anime + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .home: return "首页" + case .movie: return "电影" + case .series: return "电视剧" + case .variety: return "综艺" + case .anime: return "动漫" + case .settings: return "设置" + } + } + + var icon: String { + switch self { + case .home: return "house" + case .movie: return "film" + case .series: return "tv" + case .variety: return "theatermasks" + case .anime: return "sparkles" + case .settings: return "gearshape" + } + } + + var category: ContentCategory? { + switch self { + case .movie: return .movie + case .series: return .series + case .variety: return .variety + case .anime: return .anime + default: return nil + } + } +} + +struct AppNavigation: View { + @State private var selectedTab: AppTab = .home + // 持有各 tab 的 ViewModel,切换 tab 不丢失状态 + @State private var homeVM = HomeViewModel() + @State private var movieVM = BrowseViewModel() + @State private var seriesVM = BrowseViewModel() + @State private var varietyVM = BrowseViewModel() + @State private var animeVM = BrowseViewModel() + + var body: some View { + #if os(macOS) + sidebarLayout + #elseif os(visionOS) + sidebarLayout + #else + if UIDevice.current.userInterfaceIdiom == .pad { + sidebarLayout + } else { + tabLayout + } + #endif + } + + private var tabLayout: some View { + TabView(selection: $selectedTab) { + ForEach(AppTab.allCases) { tab in + NavigationStack { + tabContent(for: tab) + } + .tabItem { + Label(tab.title, systemImage: tab.icon) + } + .tag(tab) + } + } + } + + private var sidebarLayout: some View { + NavigationSplitView { + List(AppTab.allCases, selection: $selectedTab) { tab in + Label(tab.title, systemImage: tab.icon) + .tag(tab) + } + .navigationTitle("低端影视") + } detail: { + NavigationStack { + tabContent(for: selectedTab) + } + } + } + + @ViewBuilder + private func tabContent(for tab: AppTab) -> some View { + switch tab { + case .home: + HomeView(viewModel: homeVM) + case .movie: + BrowseView(category: .movie, viewModel: movieVM) + case .series: + BrowseView(category: .series, viewModel: seriesVM) + case .variety: + BrowseView(category: .variety, viewModel: varietyVM) + case .anime: + BrowseView(category: .anime, viewModel: animeVM) + case .settings: + SettingsView() + } + } +} diff --git a/DDYSClient/Views/Player/VideoPlayerView.swift b/DDYSClient/Views/Player/VideoPlayerView.swift new file mode 100644 index 0000000..8960666 --- /dev/null +++ b/DDYSClient/Views/Player/VideoPlayerView.swift @@ -0,0 +1,163 @@ +import SwiftUI +import AVFoundation +import AVKit + +// MARK: - 自定义 AVPlayerView 包装 + +#if os(macOS) +import AppKit + +struct NativePlayerView: NSViewRepresentable { + let player: AVPlayer + + func makeNSView(context: Context) -> AVPlayerView { + let view = AVPlayerView() + view.player = player + view.controlsStyle = .floating + view.showsFullScreenToggleButton = true + return view + } + + func updateNSView(_ nsView: AVPlayerView, context: Context) { + nsView.player = player + } +} +#else +import UIKit + +struct NativePlayerView: UIViewControllerRepresentable { + let player: AVPlayer + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let vc = AVPlayerViewController() + vc.player = player + vc.allowsPictureInPicturePlayback = true + return vc + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + uiViewController.player = player + } +} +#endif + +// MARK: - VideoPlayerView + +struct VideoPlayerView: View { + let url: String + let title: String + let episodeName: String? + let contentId: String + let episodeId: Int + + @State private var viewModel = PlayerViewModel() + + #if os(iOS) + @Environment(\.dismiss) private var dismiss + #endif + + var body: some View { + #if os(macOS) + macOSPlayer + #else + iOSPlayer + #endif + } + + #if os(iOS) + private var iOSPlayer: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let player = viewModel.player { + NativePlayerView(player: player) + .ignoresSafeArea() + } else { + ProgressView() + .tint(.white) + .foregroundStyle(.white) + } + + VStack { + HStack { + Button { + viewModel.stop() + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.white) + } + + VStack(alignment: .leading) { + Text(title) + .font(.headline) + .foregroundStyle(.white) + if let episodeName { + Text(episodeName) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + } + } + + Spacer() + + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { rate in + Button { + viewModel.setRate(Float(rate)) + } label: { + HStack { + Text("\(rate, specifier: "%.2g")x") + if viewModel.playbackRate == Float(rate) { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Text("\(viewModel.playbackRate, specifier: "%.2g")x") + .font(.subheadline) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.white.opacity(0.2), in: Capsule()) + } + } + .padding() + .background(.black.opacity(0.4)) + + Spacer() + } + } + .statusBarHidden() + .persistentSystemOverlays(.hidden) + .onAppear { + viewModel.play(url: url, contentId: contentId, episodeId: episodeId) + } + .onDisappear { + viewModel.stop() + } + } + #endif + + #if os(macOS) + private var macOSPlayer: some View { + Group { + if let player = viewModel.player { + NativePlayerView(player: player) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(minWidth: 640, minHeight: 400) + .onAppear { + viewModel.play(url: url, contentId: contentId, episodeId: episodeId) + } + .onDisappear { + viewModel.stop() + } + } + #endif +} diff --git a/DDYSClient/Views/Search/SearchView.swift b/DDYSClient/Views/Search/SearchView.swift new file mode 100644 index 0000000..2f12cab --- /dev/null +++ b/DDYSClient/Views/Search/SearchView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct SearchView: View { + @State private var viewModel = SearchViewModel() + + var body: some View { + ScrollView { + if viewModel.isSearching { + ProgressView("搜索中...") + .frame(maxWidth: .infinity, minHeight: 300) + } else if let error = viewModel.error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text(error) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } else if viewModel.hasSearched && viewModel.results.isEmpty { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundStyle(.secondary) + Text("未找到相关内容") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } else if !viewModel.results.isEmpty { + ContentGridView(items: viewModel.results) + .padding() + + if viewModel.currentPage < viewModel.totalPages { + ProgressView() + .padding() + .task { + await viewModel.loadMore() + } + } + } else { + // 搜索提示 + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 48)) + .foregroundStyle(.tertiary) + Text("输入关键词搜索影片") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } + } + .navigationTitle("搜索") + .navigationDestination(for: ContentItem.self) { item in + DetailView(item: item) + } + .searchable(text: $viewModel.query, prompt: "搜索电影、电视剧...") + .onSubmit(of: .search) { + Task { await viewModel.search() } + } + } +} diff --git a/DDYSClient/Views/Settings/SettingsView.swift b/DDYSClient/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..d30866c --- /dev/null +++ b/DDYSClient/Views/Settings/SettingsView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct SettingsView: View { + @State private var showWebLogin = false + @State private var showCookieInput = false + @State private var showClearAlert = false + private let cookieManager = CookieManager.shared + + var body: some View { + Form { + // 认证状态 + Section("认证") { + HStack { + Label("状态", systemImage: cookieManager.isAuthenticated ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(cookieManager.isAuthenticated ? .green : .secondary) + Spacer() + Text(cookieManager.isAuthenticated ? "已认证" : "未认证") + .foregroundStyle(.secondary) + } + + #if canImport(WebKit) + Button { + showWebLogin = true + } label: { + Label("WebView 登录", systemImage: "globe") + } + #endif + + Button { + showCookieInput = true + } label: { + Label("手动输入 Cookie", systemImage: "doc.text") + } + + if cookieManager.isAuthenticated { + Button(role: .destructive) { + cookieManager.clearCookies() + } label: { + Label("退出登录", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + + // 缓存管理 + Section("数据") { + Button(role: .destructive) { + showClearAlert = true + } label: { + Label("清除观看记录", systemImage: "trash") + } + } + + // 关于 + Section("关于") { + HStack { + Text("版本") + Spacer() + Text("1.0.0") + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .navigationTitle("设置") + .sheet(isPresented: $showWebLogin) { + #if canImport(WebKit) + WebLoginView() + #endif + } + .sheet(isPresented: $showCookieInput) { + CookieInputView() + } + .alert("确认清除", isPresented: $showClearAlert) { + Button("清除", role: .destructive) { + WatchProgressStore.shared.clearAll() + } + Button("取消", role: .cancel) {} + } message: { + Text("将清除所有观看进度记录,此操作不可撤销。") + } + } +} diff --git a/LocalPackages/SwiftSoup b/LocalPackages/SwiftSoup new file mode 160000 index 0000000..8b6cf29 --- /dev/null +++ b/LocalPackages/SwiftSoup @@ -0,0 +1 @@ +Subproject commit 8b6cf29eead8841a1fa7822481cb3af4ddaadba6 diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6963fb1 --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "DDYSClient", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + dependencies: [ + .package(path: "LocalPackages/SwiftSoup"), + ], + targets: [ + .executableTarget( + name: "DDYSClient", + dependencies: ["SwiftSoup"], + path: "DDYSClient" + ), + ] +) diff --git a/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..789eb5a --- /dev/null +++ b/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.839", + "green" : "0.620", + "red" : "0.227" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.710", + "red" : "0.376" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..96e7914 --- /dev/null +++ b/project.yml @@ -0,0 +1,41 @@ +name: DDYSClient +options: + bundleIdPrefix: com.fusion.ddys + deploymentTarget: + iOS: "17.0" + macOS: "14.0" + xcodeVersion: "16.0" + groupSortPosition: top + createIntermediateGroups: true + +packages: + SwiftSoup: + path: LocalPackages/SwiftSoup + +targets: + DDYSClient: + type: application + platform: macOS + sources: + - path: DDYSClient + resources: + - path: Resources + optional: true + dependencies: + - package: SwiftSoup + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.fusion.ddys.client + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: 1 + SWIFT_VERSION: "5.9" + INFOPLIST_KEY_CFBundleDisplayName: DDYS + CODE_SIGN_ENTITLEMENTS: DDYSClient/DDYSClient.entitlements + +schemes: + DDYSClient: + build: + targets: + DDYSClient: all + run: + config: Debug