init: init proj

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

View File

@@ -0,0 +1,49 @@
import SwiftUI
struct CookieInputView: View {
@Environment(\.dismiss) private var dismiss
@State private var cookieText = ""
var body: some View {
NavigationStack {
Form {
Section {
Text("请从浏览器中复制 ddys.io 的 Cookie 字符串并粘贴到下方。")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Section("Cookie") {
TextEditor(text: $cookieText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 120)
}
Section {
Text("获取方法:打开浏览器开发者工具 → Network → 任意请求 → Headers → Cookie")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle("输入 Cookie")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
let trimmed = cookieText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
CookieManager.shared.cookieString = trimmed
}
dismiss()
}
.disabled(cookieText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}

View File

@@ -0,0 +1,168 @@
import SwiftUI
#if canImport(WebKit)
import WebKit
struct WebLoginView: View {
@Environment(\.dismiss) private var dismiss
@State private var isLoading = true
var body: some View {
NavigationStack {
ZStack {
WebLoginRepresentable(
url: URL(string: "https://ddys.io")!,
isLoading: $isLoading,
onCookiesExtracted: { cookies in
CookieManager.shared.cookieString = cookies
dismiss()
}
)
if isLoading {
ProgressView("加载中...")
}
}
.navigationTitle("登录 DDYS")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("提取 Cookie") {
// WebView cookies
NotificationCenter.default.post(name: .extractCookies, object: nil)
}
}
}
}
}
}
extension Notification.Name {
static let extractCookies = Notification.Name("extractCookies")
}
#if os(iOS)
struct WebLoginRepresentable: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
let onCookiesExtracted: (String) -> Void
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
NotificationCenter.default.addObserver(
context.coordinator,
selector: #selector(Coordinator.extractCookies),
name: .extractCookies,
object: nil
)
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, WKNavigationDelegate {
let parent: WebLoginRepresentable
weak var webView: WKWebView?
init(parent: WebLoginRepresentable) {
self.parent = parent
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
self.webView = webView
parent.isLoading = true
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
parent.isLoading = false
}
@objc func extractCookies() {
guard let webView else { return }
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
let ddysCookies = cookies
.filter { $0.domain.contains("ddys") }
.map { "\($0.name)=\($0.value)" }
.joined(separator: "; ")
DispatchQueue.main.async {
self.parent.onCookiesExtracted(ddysCookies)
}
}
}
}
}
#else
struct WebLoginRepresentable: NSViewRepresentable {
let url: URL
@Binding var isLoading: Bool
let onCookiesExtracted: (String) -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
NotificationCenter.default.addObserver(
context.coordinator,
selector: #selector(Coordinator.extractCookies),
name: .extractCookies,
object: nil
)
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, WKNavigationDelegate {
let parent: WebLoginRepresentable
weak var webView: WKWebView?
init(parent: WebLoginRepresentable) {
self.parent = parent
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
self.webView = webView
parent.isLoading = true
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
parent.isLoading = false
}
@objc func extractCookies() {
guard let webView else { return }
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
let ddysCookies = cookies
.filter { $0.domain.contains("ddys") }
.map { "\($0.name)=\($0.value)" }
.joined(separator: "; ")
DispatchQueue.main.async {
self.parent.onCookiesExtracted(ddysCookies)
}
}
}
}
}
#endif
#endif

View File

@@ -0,0 +1,84 @@
import SwiftUI
struct BrowseView: View {
let category: ContentCategory
@Bindable var viewModel: BrowseViewModel
var body: some View {
Group {
if !viewModel.hasData && viewModel.error == nil {
VStack(spacing: 0) {
FilterBarView(filter: $viewModel.filter) {
Task { await viewModel.applyFilter(viewModel.filter) }
}
.padding(.vertical, 8)
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
ScrollView {
VStack(spacing: 0) {
FilterBarView(filter: $viewModel.filter) {
Task { await viewModel.applyFilter(viewModel.filter) }
}
.padding(.vertical, 8)
if let error = viewModel.error {
errorView(error)
} else if viewModel.items.isEmpty {
emptyView
} else {
ContentGridView(items: viewModel.items) {
Task { await viewModel.loadMore() }
}
.padding(.horizontal)
.padding(.top, 8)
if viewModel.isLoadingMore {
ProgressView()
.padding()
}
}
}
}
}
}
.navigationTitle(category.displayName)
.navigationDestination(for: ContentItem.self) { item in
DetailView(item: item)
}
.refreshable {
await viewModel.loadContent()
}
.task {
viewModel.category = category
await viewModel.loadContentIfNeeded()
}
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(message)
.foregroundStyle(.secondary)
Button("重试") {
Task { await viewModel.loadContent() }
}
}
.frame(maxWidth: .infinity, minHeight: 300)
}
private var emptyView: some View {
VStack(spacing: 12) {
Image(systemName: "film.stack")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("暂无内容")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
struct ContentCardView: View {
let item: ContentItem
private let cardWidth: CGFloat = 140
private let aspectRatio: CGFloat = 2.0 / 3.0
var body: some View {
VStack(alignment: .leading, spacing: 6) {
//
ZStack(alignment: .topLeading) {
posterImage
.frame(width: cardWidth, height: cardWidth / aspectRatio)
.clipShape(RoundedRectangle(cornerRadius: 8))
//
if let rating = item.rating, rating > 0 {
Text(String(format: "%.1f", rating))
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.gradient, in: RoundedRectangle(cornerRadius: 4))
.padding(6)
.frame(maxWidth: .infinity, alignment: .topTrailing)
}
//
if let badge = item.badges.first {
Text(badge)
.font(.caption2)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 4))
.padding(6)
}
}
//
Text(item.title)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
.frame(width: cardWidth, alignment: .leading)
//
if item.year > 0 {
Text("\(String(item.year))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: cardWidth)
}
@ViewBuilder
private var posterImage: some View {
CachedAsyncImage(url: item.posterURL) {
posterPlaceholder
}
}
private var posterPlaceholder: some View {
Rectangle()
.fill(.quaternary)
.overlay {
Image(systemName: "film")
.font(.title)
.foregroundStyle(.tertiary)
}
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct ContentGridView: View {
let items: [ContentItem]
var onNearEnd: (() -> Void)?
private let columns = [
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
NavigationLink(value: item) {
ContentCardView(item: item)
}
.buttonStyle(.plain)
.onAppear {
if let onNearEnd, isNearEnd(item) {
onNearEnd()
}
}
}
}
}
private func isNearEnd(_ item: ContentItem) -> Bool {
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return false }
return index >= items.count - 4
}
}

View File

@@ -0,0 +1,55 @@
import SwiftUI
struct FilterBarView: View {
@Binding var filter: FilterState
var onFilterChanged: () -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort)
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
}
.padding(.horizontal)
}
}
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
Menu {
ForEach(options) { option in
Button {
selection.wrappedValue = option.value
onFilterChanged()
} label: {
HStack {
Text(option.name)
if selection.wrappedValue == option.value {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(selectedName(for: selection.wrappedValue, in: options) ?? title)
.font(.subheadline)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
selection.wrappedValue.isEmpty
? Color.secondary.opacity(0.12)
: Color.accentColor.opacity(0.15),
in: Capsule()
)
.foregroundStyle(selection.wrappedValue.isEmpty ? Color.primary : Color.accentColor)
}
}
private func selectedName(for value: String, in options: [FilterOption]) -> String? {
guard !value.isEmpty else { return nil }
return options.first { $0.value == value }?.name
}
}

View File

@@ -0,0 +1,92 @@
import SwiftUI
struct CachedAsyncImage<Placeholder: View>: View {
let url: URL?
@ViewBuilder let placeholder: () -> Placeholder
@State private var image: Image?
@State private var isLoading = false
var body: some View {
Group {
if let image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
placeholder()
.task(id: url) {
await loadImage()
}
}
}
}
private func loadImage() async {
guard let url, !isLoading else { return }
//
if let cached = ImageCache.shared.get(url) {
self.image = cached
return
}
isLoading = true
defer { isLoading = false }
do {
let (data, _) = try await ImageCache.shared.session.data(from: url)
#if os(macOS)
if let nsImage = NSImage(data: data) {
let img = Image(nsImage: nsImage)
ImageCache.shared.set(img, for: url)
self.image = img
}
#else
if let uiImage = UIImage(data: data) {
let img = Image(uiImage: uiImage)
ImageCache.shared.set(img, for: url)
self.image = img
}
#endif
} catch {
// placeholder
}
}
}
// MARK: -
private final class ImageCache: @unchecked Sendable {
static let shared = ImageCache()
private let cache = NSCache<NSURL, CacheEntry>()
let session: URLSession
private init() {
cache.countLimit = 200
cache.totalCostLimit = 100 * 1024 * 1024 // 100MB
//
let config = URLSessionConfiguration.default
config.urlCache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50MB
diskCapacity: 200 * 1024 * 1024 // 200MB
)
config.requestCachePolicy = .returnCacheDataElseLoad
session = URLSession(configuration: config)
}
func get(_ url: URL) -> Image? {
cache.object(forKey: url as NSURL)?.image
}
func set(_ image: Image, for url: URL) {
cache.setObject(CacheEntry(image: image), forKey: url as NSURL)
}
}
private final class CacheEntry {
let image: Image
init(image: Image) { self.image = image }
}

View File

@@ -0,0 +1,289 @@
import SwiftUI
struct DetailView: View {
let item: ContentItem
@State private var viewModel = DetailViewModel()
#if os(macOS)
@Environment(\.openWindow) private var openWindow
#else
@State private var showPlayer = false
#endif
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.error {
errorView(error)
} else if let detail = viewModel.detail {
ScrollView {
detailContent(detail)
}
}
}
.navigationTitle(item.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task {
if viewModel.detail == nil {
await viewModel.loadDetail(path: item.detailURL)
}
}
#if os(iOS)
.sheet(isPresented: $showPlayer) {
if let episode = viewModel.currentEpisode {
VideoPlayerView(
url: episode.url,
title: item.title,
episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil,
contentId: item.id,
episodeId: episode.id
)
}
}
#endif
}
private func playVideo() {
guard let episode = viewModel.currentEpisode else { return }
#if os(macOS)
let data = VideoPlayerData(
url: episode.url,
title: item.title,
episodeName: viewModel.hasMultipleEpisodes ? episode.name : nil,
contentId: item.id,
episodeId: episode.id
)
openWindow(value: data)
#else
showPlayer = true
#endif
}
@ViewBuilder
private func detailContent(_ detail: ContentDetail) -> some View {
VStack(alignment: .leading, spacing: 20) {
// +
HStack(alignment: .top, spacing: 16) {
posterImage(detail)
.frame(width: 160, height: 240)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(radius: 4)
VStack(alignment: .leading, spacing: 8) {
Text(detail.item.title)
.font(.title2)
.fontWeight(.bold)
.lineLimit(3)
//
if let rating = detail.item.rating, rating > 0 {
HStack(spacing: 4) {
Image(systemName: "star.fill")
.foregroundStyle(.orange)
Text(String(format: "%.1f", rating))
.fontWeight(.semibold)
}
}
// ·
if detail.item.year > 0 || !detail.region.isEmpty {
let parts = [
detail.item.year > 0 ? "\(detail.item.year)" : nil,
detail.region.isEmpty ? nil : detail.region,
].compactMap { $0 }
Text(parts.joined(separator: " · "))
.font(.subheadline)
.foregroundStyle(.secondary)
}
//
if !detail.genres.isEmpty {
FlowLayout(spacing: 6) {
ForEach(detail.genres, id: \.self) { genre in
Text(genre)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.secondary.opacity(0.12), in: Capsule())
}
}
}
Spacer(minLength: 0)
//
if !detail.sources.isEmpty {
Button {
playVideo()
} label: {
Label("立即播放", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
}
}
.padding(.horizontal)
//
if detail.sources.count > 1 {
VStack(alignment: .leading, spacing: 8) {
Text("播放源")
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(detail.sources.enumerated()), id: \.element.id) { index, source in
Button {
viewModel.selectSource(index)
} label: {
Text(source.name)
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
viewModel.selectedSourceIndex == index
? Color.accentColor
: Color.secondary.opacity(0.12),
in: Capsule()
)
.foregroundStyle(
viewModel.selectedSourceIndex == index ? .white : .primary
)
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal)
}
//
if viewModel.hasMultipleEpisodes, let source = viewModel.currentSource {
EpisodeListView(
episodes: source.episodes,
selectedIndex: viewModel.selectedEpisodeIndex
) { index in
viewModel.selectEpisode(index)
playVideo()
}
.padding(.horizontal)
}
Divider().padding(.horizontal)
//
if !detail.description.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("剧情简介")
.font(.headline)
Text(detail.description)
.font(.body)
.foregroundStyle(.secondary)
.lineSpacing(4)
}
.padding(.horizontal)
}
//
if !detail.directors.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("导演")
.font(.headline)
Text(detail.directors.joined(separator: " / "))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
}
//
if !detail.actors.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("主演")
.font(.headline)
Text(detail.actors.joined(separator: " / "))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
}
Spacer(minLength: 32)
}
.padding(.vertical)
}
@ViewBuilder
private func posterImage(_ detail: ContentDetail) -> some View {
CachedAsyncImage(url: detail.item.posterURL) {
Rectangle()
.fill(.quaternary)
.overlay {
Image(systemName: "film")
.font(.largeTitle)
.foregroundStyle(.tertiary)
}
}
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(message)
.foregroundStyle(.secondary)
Button("重试") {
Task { await viewModel.loadDetail(path: item.detailURL) }
}
}
.frame(maxWidth: .infinity, minHeight: 400)
}
}
// MARK: - FlowLayout for genre tags
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrange(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrange(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
}
}
private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var maxX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
positions.append(CGPoint(x: x, y: y))
rowHeight = max(rowHeight, size.height)
x += size.width + spacing
maxX = max(maxX, x)
}
return (CGSize(width: maxX, height: y + rowHeight), positions)
}
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
struct EpisodeListView: View {
let episodes: [Episode]
let selectedIndex: Int
let onSelect: (Int) -> Void
private let columns = [
GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8)
]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("选集 (\(episodes.count)集)")
.font(.headline)
LazyVGrid(columns: columns, spacing: 8) {
ForEach(Array(episodes.enumerated()), id: \.element.id) { index, episode in
Button {
onSelect(index)
} label: {
Text(episode.name)
.font(.caption)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(
selectedIndex == index
? Color.accentColor
: Color.secondary.opacity(0.12),
in: RoundedRectangle(cornerRadius: 6)
)
.foregroundStyle(selectedIndex == index ? .white : .primary)
}
.buttonStyle(.plain)
}
}
}
}
}

View File

@@ -0,0 +1,160 @@
import SwiftUI
struct HomeView: View {
var viewModel: HomeViewModel
@State private var searchText = ""
@State private var searchViewModel = SearchViewModel()
private var showingSearchResults: Bool {
searchViewModel.hasSearched
}
var body: some View {
Group {
if !viewModel.hasData && viewModel.error == nil && !showingSearchResults {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if showingSearchResults && searchViewModel.isSearching {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
if showingSearchResults {
searchContent
} else {
homeContent
}
}
}
}
.navigationTitle("低端影视")
.navigationDestination(for: ContentItem.self) { item in
DetailView(item: item)
}
.searchable(text: $searchText, prompt: "搜索电影、电视剧...")
.onSubmit(of: .search) {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
Task {
searchViewModel.query = trimmed
await searchViewModel.search()
}
}
.onChange(of: searchText) { _, newValue in
if newValue.isEmpty {
searchViewModel.clear()
}
}
.refreshable {
if showingSearchResults {
await searchViewModel.search()
} else {
await viewModel.loadHome()
}
}
.task {
await viewModel.loadHomeIfNeeded()
}
}
// MARK: -
@ViewBuilder
private var homeContent: some View {
if let error = viewModel.error {
errorView(error)
} else {
LazyVStack(alignment: .leading, spacing: 24) {
if !viewModel.recommendedItems.isEmpty {
sectionHeader("热门推荐")
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(viewModel.recommendedItems) { item in
NavigationLink(value: item) {
ContentCardView(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
if !viewModel.latestItems.isEmpty {
sectionHeader("最新更新")
ContentGridView(items: viewModel.latestItems)
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
// MARK: -
@ViewBuilder
private var searchContent: some View {
if let error = searchViewModel.error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(error)
.foregroundStyle(.secondary)
Button("重试") {
Task { await searchViewModel.search() }
}
}
.frame(maxWidth: .infinity, minHeight: 300)
} else if searchViewModel.results.isEmpty {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("未找到「\(searchViewModel.query)」的相关内容")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
} else {
LazyVStack(alignment: .leading, spacing: 16) {
Text("搜索结果")
.font(.title2)
.fontWeight(.bold)
.padding(.horizontal)
ContentGridView(items: searchViewModel.results) {
Task { await searchViewModel.loadMore() }
}
.padding(.horizontal)
if searchViewModel.isLoadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
}
.padding(.vertical)
}
}
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.title2)
.fontWeight(.bold)
.padding(.horizontal)
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(message)
.foregroundStyle(.secondary)
Button("重试") {
Task { await viewModel.loadHome() }
}
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
enum AppTab: String, CaseIterable, Identifiable {
case home
case movie
case series
case variety
case anime
case settings
var id: String { rawValue }
var title: String {
switch self {
case .home: return "首页"
case .movie: return "电影"
case .series: return "电视剧"
case .variety: return "综艺"
case .anime: return "动漫"
case .settings: return "设置"
}
}
var icon: String {
switch self {
case .home: return "house"
case .movie: return "film"
case .series: return "tv"
case .variety: return "theatermasks"
case .anime: return "sparkles"
case .settings: return "gearshape"
}
}
var category: ContentCategory? {
switch self {
case .movie: return .movie
case .series: return .series
case .variety: return .variety
case .anime: return .anime
default: return nil
}
}
}
struct AppNavigation: View {
@State private var selectedTab: AppTab = .home
// tab ViewModel tab
@State private var homeVM = HomeViewModel()
@State private var movieVM = BrowseViewModel()
@State private var seriesVM = BrowseViewModel()
@State private var varietyVM = BrowseViewModel()
@State private var animeVM = BrowseViewModel()
var body: some View {
#if os(macOS)
sidebarLayout
#elseif os(visionOS)
sidebarLayout
#else
if UIDevice.current.userInterfaceIdiom == .pad {
sidebarLayout
} else {
tabLayout
}
#endif
}
private var tabLayout: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
NavigationStack {
tabContent(for: tab)
}
.tabItem {
Label(tab.title, systemImage: tab.icon)
}
.tag(tab)
}
}
}
private var sidebarLayout: some View {
NavigationSplitView {
List(AppTab.allCases, selection: $selectedTab) { tab in
Label(tab.title, systemImage: tab.icon)
.tag(tab)
}
.navigationTitle("低端影视")
} detail: {
NavigationStack {
tabContent(for: selectedTab)
}
}
}
@ViewBuilder
private func tabContent(for tab: AppTab) -> some View {
switch tab {
case .home:
HomeView(viewModel: homeVM)
case .movie:
BrowseView(category: .movie, viewModel: movieVM)
case .series:
BrowseView(category: .series, viewModel: seriesVM)
case .variety:
BrowseView(category: .variety, viewModel: varietyVM)
case .anime:
BrowseView(category: .anime, viewModel: animeVM)
case .settings:
SettingsView()
}
}
}

View File

@@ -0,0 +1,163 @@
import SwiftUI
import AVFoundation
import AVKit
// MARK: - AVPlayerView
#if os(macOS)
import AppKit
struct NativePlayerView: NSViewRepresentable {
let player: AVPlayer
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = player
view.controlsStyle = .floating
view.showsFullScreenToggleButton = true
return view
}
func updateNSView(_ nsView: AVPlayerView, context: Context) {
nsView.player = player
}
}
#else
import UIKit
struct NativePlayerView: UIViewControllerRepresentable {
let player: AVPlayer
func makeUIViewController(context: Context) -> AVPlayerViewController {
let vc = AVPlayerViewController()
vc.player = player
vc.allowsPictureInPicturePlayback = true
return vc
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}
#endif
// MARK: - VideoPlayerView
struct VideoPlayerView: View {
let url: String
let title: String
let episodeName: String?
let contentId: String
let episodeId: Int
@State private var viewModel = PlayerViewModel()
#if os(iOS)
@Environment(\.dismiss) private var dismiss
#endif
var body: some View {
#if os(macOS)
macOSPlayer
#else
iOSPlayer
#endif
}
#if os(iOS)
private var iOSPlayer: some View {
ZStack {
Color.black.ignoresSafeArea()
if let player = viewModel.player {
NativePlayerView(player: player)
.ignoresSafeArea()
} else {
ProgressView()
.tint(.white)
.foregroundStyle(.white)
}
VStack {
HStack {
Button {
viewModel.stop()
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.white)
}
VStack(alignment: .leading) {
Text(title)
.font(.headline)
.foregroundStyle(.white)
if let episodeName {
Text(episodeName)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
}
Spacer()
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { rate in
Button {
viewModel.setRate(Float(rate))
} label: {
HStack {
Text("\(rate, specifier: "%.2g")x")
if viewModel.playbackRate == Float(rate) {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Text("\(viewModel.playbackRate, specifier: "%.2g")x")
.font(.subheadline)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.white.opacity(0.2), in: Capsule())
}
}
.padding()
.background(.black.opacity(0.4))
Spacer()
}
}
.statusBarHidden()
.persistentSystemOverlays(.hidden)
.onAppear {
viewModel.play(url: url, contentId: contentId, episodeId: episodeId)
}
.onDisappear {
viewModel.stop()
}
}
#endif
#if os(macOS)
private var macOSPlayer: some View {
Group {
if let player = viewModel.player {
NativePlayerView(player: player)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(minWidth: 640, minHeight: 400)
.onAppear {
viewModel.play(url: url, contentId: contentId, episodeId: episodeId)
}
.onDisappear {
viewModel.stop()
}
}
#endif
}

View File

@@ -0,0 +1,61 @@
import SwiftUI
struct SearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
ScrollView {
if viewModel.isSearching {
ProgressView("搜索中...")
.frame(maxWidth: .infinity, minHeight: 300)
} else if let error = viewModel.error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(error)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
} else if viewModel.hasSearched && viewModel.results.isEmpty {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("未找到相关内容")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
} else if !viewModel.results.isEmpty {
ContentGridView(items: viewModel.results)
.padding()
if viewModel.currentPage < viewModel.totalPages {
ProgressView()
.padding()
.task {
await viewModel.loadMore()
}
}
} else {
//
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.tertiary)
Text("输入关键词搜索影片")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}
.navigationTitle("搜索")
.navigationDestination(for: ContentItem.self) { item in
DetailView(item: item)
}
.searchable(text: $viewModel.query, prompt: "搜索电影、电视剧...")
.onSubmit(of: .search) {
Task { await viewModel.search() }
}
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct SettingsView: View {
@State private var showWebLogin = false
@State private var showCookieInput = false
@State private var showClearAlert = false
private let cookieManager = CookieManager.shared
var body: some View {
Form {
//
Section("认证") {
HStack {
Label("状态", systemImage: cookieManager.isAuthenticated ? "checkmark.circle.fill" : "xmark.circle")
.foregroundStyle(cookieManager.isAuthenticated ? .green : .secondary)
Spacer()
Text(cookieManager.isAuthenticated ? "已认证" : "未认证")
.foregroundStyle(.secondary)
}
#if canImport(WebKit)
Button {
showWebLogin = true
} label: {
Label("WebView 登录", systemImage: "globe")
}
#endif
Button {
showCookieInput = true
} label: {
Label("手动输入 Cookie", systemImage: "doc.text")
}
if cookieManager.isAuthenticated {
Button(role: .destructive) {
cookieManager.clearCookies()
} label: {
Label("退出登录", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
//
Section("数据") {
Button(role: .destructive) {
showClearAlert = true
} label: {
Label("清除观看记录", systemImage: "trash")
}
}
//
Section("关于") {
HStack {
Text("版本")
Spacer()
Text("1.0.0")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.navigationTitle("设置")
.sheet(isPresented: $showWebLogin) {
#if canImport(WebKit)
WebLoginView()
#endif
}
.sheet(isPresented: $showCookieInput) {
CookieInputView()
}
.alert("确认清除", isPresented: $showClearAlert) {
Button("清除", role: .destructive) {
WatchProgressStore.shared.clearAll()
}
Button("取消", role: .cancel) {}
} message: {
Text("将清除所有观看进度记录,此操作不可撤销。")
}
}
}