init: init proj
This commit is contained in:
26
.claude/settings.local.json
Normal file
26
.claude/settings.local.json
Normal 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
110
.gitignore
vendored
Normal 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
|
||||
7
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
7
DDYSClient/App/ContentView.swift
Normal file
7
DDYSClient/App/ContentView.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
36
DDYSClient/App/DDYSClientApp.swift
Normal file
36
DDYSClient/App/DDYSClientApp.swift
Normal 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
|
||||
}
|
||||
}
|
||||
10
DDYSClient/DDYSClient.entitlements
Normal file
10
DDYSClient/DDYSClient.entitlements
Normal 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>
|
||||
32
DDYSClient/Models/ContentCategory.swift
Normal file
32
DDYSClient/Models/ContentCategory.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
DDYSClient/Models/ContentDetail.swift
Normal file
12
DDYSClient/Models/ContentDetail.swift
Normal 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]?
|
||||
}
|
||||
22
DDYSClient/Models/ContentItem.swift
Normal file
22
DDYSClient/Models/ContentItem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
7
DDYSClient/Models/Episode.swift
Normal file
7
DDYSClient/Models/Episode.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct Episode: Identifiable, Codable, Hashable {
|
||||
let id: Int
|
||||
let name: String // "第01集"
|
||||
let url: String // m3u8 地址
|
||||
}
|
||||
93
DDYSClient/Models/FilterOption.swift
Normal file
93
DDYSClient/Models/FilterOption.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
8
DDYSClient/Models/StreamSource.swift
Normal file
8
DDYSClient/Models/StreamSource.swift
Normal 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]
|
||||
}
|
||||
18
DDYSClient/Models/VideoPlayerData.swift
Normal file
18
DDYSClient/Models/VideoPlayerData.swift
Normal 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
|
||||
}
|
||||
}
|
||||
121
DDYSClient/Services/APIClient.swift
Normal file
121
DDYSClient/Services/APIClient.swift
Normal 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 }
|
||||
}
|
||||
24
DDYSClient/Services/ContentCache.swift
Normal file
24
DDYSClient/Services/ContentCache.swift
Normal 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
|
||||
}
|
||||
}
|
||||
64
DDYSClient/Services/CookieManager.swift
Normal file
64
DDYSClient/Services/CookieManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
376
DDYSClient/Services/HTMLParser.swift
Normal file
376
DDYSClient/Services/HTMLParser.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
69
DDYSClient/Services/WatchProgressStore.swift
Normal file
69
DDYSClient/Services/WatchProgressStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
36
DDYSClient/Utilities/Extensions.swift
Normal file
36
DDYSClient/Utilities/Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
98
DDYSClient/ViewModels/BrowseViewModel.swift
Normal file
98
DDYSClient/ViewModels/BrowseViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
66
DDYSClient/ViewModels/DetailViewModel.swift
Normal file
66
DDYSClient/ViewModels/DetailViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
65
DDYSClient/ViewModels/HomeViewModel.swift
Normal file
65
DDYSClient/ViewModels/HomeViewModel.swift
Normal 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 失败不影响主页显示
|
||||
}
|
||||
}
|
||||
}
|
||||
134
DDYSClient/ViewModels/PlayerViewModel.swift
Normal file
134
DDYSClient/ViewModels/PlayerViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
75
DDYSClient/ViewModels/SearchViewModel.swift
Normal file
75
DDYSClient/ViewModels/SearchViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
49
DDYSClient/Views/Auth/CookieInputView.swift
Normal file
49
DDYSClient/Views/Auth/CookieInputView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
DDYSClient/Views/Auth/WebLoginView.swift
Normal file
168
DDYSClient/Views/Auth/WebLoginView.swift
Normal 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
|
||||
84
DDYSClient/Views/Browse/BrowseView.swift
Normal file
84
DDYSClient/Views/Browse/BrowseView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
75
DDYSClient/Views/Browse/ContentCardView.swift
Normal file
75
DDYSClient/Views/Browse/ContentCardView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
31
DDYSClient/Views/Browse/ContentGridView.swift
Normal file
31
DDYSClient/Views/Browse/ContentGridView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
55
DDYSClient/Views/Browse/FilterBarView.swift
Normal file
55
DDYSClient/Views/Browse/FilterBarView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
92
DDYSClient/Views/Common/CachedAsyncImage.swift
Normal file
92
DDYSClient/Views/Common/CachedAsyncImage.swift
Normal 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 }
|
||||
}
|
||||
289
DDYSClient/Views/Detail/DetailView.swift
Normal file
289
DDYSClient/Views/Detail/DetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
40
DDYSClient/Views/Detail/EpisodeListView.swift
Normal file
40
DDYSClient/Views/Detail/EpisodeListView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
DDYSClient/Views/Home/HomeView.swift
Normal file
160
DDYSClient/Views/Home/HomeView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
114
DDYSClient/Views/Navigation/AppNavigation.swift
Normal file
114
DDYSClient/Views/Navigation/AppNavigation.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
163
DDYSClient/Views/Player/VideoPlayerView.swift
Normal file
163
DDYSClient/Views/Player/VideoPlayerView.swift
Normal 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
|
||||
}
|
||||
61
DDYSClient/Views/Search/SearchView.swift
Normal file
61
DDYSClient/Views/Search/SearchView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
82
DDYSClient/Views/Settings/SettingsView.swift
Normal file
82
DDYSClient/Views/Settings/SettingsView.swift
Normal 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("将清除所有观看进度记录,此操作不可撤销。")
|
||||
}
|
||||
}
|
||||
}
|
||||
1
LocalPackages/SwiftSoup
Submodule
1
LocalPackages/SwiftSoup
Submodule
Submodule LocalPackages/SwiftSoup added at 8b6cf29eea
20
Package.swift
Normal file
20
Package.swift
Normal 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"
|
||||
),
|
||||
]
|
||||
)
|
||||
38
Resources/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
38
Resources/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
Resources/Assets.xcassets/Contents.json
Normal file
6
Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
41
project.yml
Normal file
41
project.yml
Normal 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
|
||||
Reference in New Issue
Block a user