init: init proj

This commit is contained in:
2026-02-26 22:15:35 +08:00
commit 7ef5348f65
43 changed files with 3085 additions and 0 deletions

View File

@@ -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:*)"
]
}
}

110
.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,7 @@
import SwiftUI
struct ContentView: View {
var body: some View {
AppNavigation()
}
}

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import Foundation
struct Episode: Identifiable, Codable, Hashable {
let id: Int
let name: String // "01"
let url: String // m3u8
}

View File

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

View File

@@ -0,0 +1,8 @@
import Foundation
struct StreamSource: Identifiable, Codable {
let id: Int
let name: String // " 1"
let quality: String // "1080P"
let episodes: [Episode]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <span></span><span>xxx</span>
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]
}
}

View File

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

View File

@@ -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`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import SwiftUI
struct CachedAsyncImage<Placeholder: View>: 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<NSURL, CacheEntry>()
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 }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("将清除所有观看进度记录,此操作不可撤销。")
}
}
}

Submodule LocalPackages/SwiftSoup added at 8b6cf29eea

20
Package.swift Normal file
View File

@@ -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"
),
]
)

View File

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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

41
project.yml Normal file
View File

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