init: init proj
This commit is contained in:
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("将清除所有观看进度记录,此操作不可撤销。")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user