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