Compare commits

4 Commits

Author SHA1 Message Date
2e5348cc97 feat: fix detail 2026-02-27 13:50:32 +08:00
bcfee96fa9 Merge branch 'main' into feature_tvOS 2026-02-27 11:35:24 +08:00
1da412e637 fix: 修复详情页空白和海报图片错误
- DetailViewModel 添加 @MainActor 确保状态更新在主线程
- DetailView 消除空白初始状态,ProgressView 作为默认兜底
- 取消时保留加载状态避免页面闪回空白
- 使用 .task(id:) 确保切换条目时任务重新触发
- 海报优先从 JSON-LD image 字段获取,HTML fallback 改用正确选择器

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:33:22 +08:00
cd8c3afd7d feat: for tvOS 2026-02-26 22:45:05 +08:00
19 changed files with 295 additions and 26 deletions

View File

@@ -20,7 +20,12 @@
"Bash(xargs -I{} sample {} 1)", "Bash(xargs -I{} sample {} 1)",
"Bash(killall xcodebuild:*)", "Bash(killall xcodebuild:*)",
"Bash(cp:*)", "Bash(cp:*)",
"Bash(pkill:*)" "Bash(pkill:*)",
"Bash(git:*)",
"Bash(xcrun simctl:*)",
"Bash(xcrun devicectl:*)",
"Bash(networksetup:*)",
"Bash(system_profiler SPNetworkDataType:*)"
] ]
} }
} }

View File

@@ -93,10 +93,6 @@ enum HTMLParser {
let h1 = try doc.select("h1").first() let h1 = try doc.select("h1").first()
let fullTitle = try h1?.text().trimmingCharacters(in: .whitespacesAndNewlines) ?? "未知标题" 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 === // === JSON-LD ===
var year = 0 var year = 0
var rating: Double? var rating: Double?
@@ -105,12 +101,17 @@ enum HTMLParser {
var genres: [String] = [] var genres: [String] = []
var description = "" var description = ""
var region = "" var region = ""
var posterURL: URL?
if let jsonLDScript = try doc.select("script[type=application/ld+json]").first() { if let jsonLDScript = try doc.select("script[type=application/ld+json]").first() {
let jsonText = try jsonLDScript.data() let jsonText = try jsonLDScript.data()
if let data = jsonText.data(using: .utf8), if let data = jsonText.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let imageStr = json["image"] as? String {
posterURL = URL(string: imageStr)
}
if let published = json["datePublished"] as? String { if let published = json["datePublished"] as? String {
year = Int(published) ?? 0 year = Int(published) ?? 0
} }
@@ -140,6 +141,12 @@ enum HTMLParser {
// === HTML === // === HTML ===
// fallback: .flex-shrink-0
if posterURL == nil {
let imgSrc = try doc.select(".flex-shrink-0 img.object-cover").first()?.attr("src") ?? ""
posterURL = URL(string: imgSrc)
}
// fallback // fallback
if rating == nil { if rating == nil {
let ratingText = try doc.select(".rating-display").text() let ratingText = try doc.select(".rating-display").text()

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
@Observable @Observable @MainActor
final class DetailViewModel { final class DetailViewModel {
var detail: ContentDetail? var detail: ContentDetail?
var selectedSourceIndex = 0 var selectedSourceIndex = 0
@@ -27,23 +27,22 @@ final class DetailViewModel {
func loadDetail(path: String) async { func loadDetail(path: String) async {
isLoading = true isLoading = true
error = nil error = nil
defer { isLoading = false }
do { do {
let html = try await APIClient.shared.fetchDetailPage(path: path) let html = try await APIClient.shared.fetchDetailPage(path: path)
let parsedDetail = try HTMLParser.parseContentDetail(html: html) let parsedDetail = try HTMLParser.parseContentDetail(html: html)
await MainActor.run { self.detail = parsedDetail
self.detail = parsedDetail self.selectedSourceIndex = 0
self.selectedSourceIndex = 0 self.selectedEpisodeIndex = 0
self.selectedEpisodeIndex = 0
}
} catch is CancellationError { } catch is CancellationError {
// loading
return return
} catch let error as URLError where error.code == .cancelled { } catch let error as URLError where error.code == .cancelled {
return return
} catch { } catch {
await MainActor.run { self.error = error.localizedDescription } self.error = error.localizedDescription
} }
isLoading = false
} }
func selectSource(_ index: Int) { func selectSource(_ index: Int) {

View File

@@ -14,9 +14,14 @@ struct CookieInputView: View {
} }
Section("Cookie") { Section("Cookie") {
#if os(tvOS)
TextField("粘贴 Cookie...", text: $cookieText)
.font(.system(.body, design: .monospaced))
#else
TextEditor(text: $cookieText) TextEditor(text: $cookieText)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.frame(minHeight: 120) .frame(minHeight: 120)
#endif
} }
Section { Section {

View File

@@ -48,9 +48,11 @@ struct BrowseView: View {
.navigationDestination(for: ContentItem.self) { item in .navigationDestination(for: ContentItem.self) { item in
DetailView(item: item) DetailView(item: item)
} }
#if !os(tvOS)
.refreshable { .refreshable {
await viewModel.loadContent() await viewModel.loadContent()
} }
#endif
.task { .task {
viewModel.category = category viewModel.category = category
await viewModel.loadContentIfNeeded() await viewModel.loadContentIfNeeded()

View File

@@ -3,9 +3,17 @@ import SwiftUI
struct ContentCardView: View { struct ContentCardView: View {
let item: ContentItem let item: ContentItem
#if os(tvOS)
private let cardWidth: CGFloat = 250
#else
private let cardWidth: CGFloat = 140 private let cardWidth: CGFloat = 140
#endif
private let aspectRatio: CGFloat = 2.0 / 3.0 private let aspectRatio: CGFloat = 2.0 / 3.0
#if os(tvOS)
@Environment(\.isFocused) private var isFocused
#endif
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
// //
@@ -54,6 +62,11 @@ struct ContentCardView: View {
} }
} }
.frame(width: cardWidth) .frame(width: cardWidth)
#if os(tvOS)
.scaleEffect(isFocused ? 1.1 : 1.0)
.shadow(color: isFocused ? .blue.opacity(0.4) : .clear, radius: 10)
.animation(.easeInOut(duration: 0.2), value: isFocused)
#endif
} }
@ViewBuilder @ViewBuilder

View File

@@ -4,9 +4,15 @@ struct ContentGridView: View {
let items: [ContentItem] let items: [ContentItem]
var onNearEnd: (() -> Void)? var onNearEnd: (() -> Void)?
#if os(tvOS)
private let columns = [
GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 24)
]
#else
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12) GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
] ]
#endif
var body: some View { var body: some View {
LazyVGrid(columns: columns, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) {

View File

@@ -7,15 +7,47 @@ struct FilterBarView: View {
var body: some View { var body: some View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) { HStack(spacing: 16) {
#if os(tvOS)
filterPicker("排序", options: FilterState.defaultSorts, selection: $filter.sort)
filterPicker("类型", options: FilterState.defaultGenres, selection: $filter.genre)
filterPicker("地区", options: FilterState.defaultRegions, selection: $filter.region)
filterPicker("年份", options: FilterState.defaultYears, selection: $filter.year)
#else
filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort) filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort)
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre) filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region) filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year) filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
#endif
} }
.padding(.horizontal) .padding(.horizontal)
} }
} }
#if os(tvOS)
private func filterPicker(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
HStack(spacing: 8) {
ForEach(options) { option in
Button {
selection.wrappedValue = option.value
onFilterChanged()
} label: {
Text(option.name)
.font(.subheadline)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
selection.wrappedValue == option.value
? Color.accentColor
: Color.secondary.opacity(0.12),
in: Capsule()
)
.foregroundStyle(selection.wrappedValue == option.value ? .white : .primary)
}
.buttonStyle(.plain)
}
}
}
#else
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View { private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
Menu { Menu {
ForEach(options) { option in ForEach(options) { option in
@@ -52,4 +84,5 @@ struct FilterBarView: View {
guard !value.isEmpty else { return nil } guard !value.isEmpty else { return nil }
return options.first { $0.value == value }?.name return options.first { $0.value == value }?.name
} }
#endif
} }

View File

@@ -10,29 +10,45 @@ struct DetailView: View {
@State private var showPlayer = false @State private var showPlayer = false
#endif #endif
#if os(tvOS)
@Environment(\.dismiss) private var dismiss
#endif
var body: some View { var body: some View {
Group { Group {
if viewModel.isLoading { if let detail = viewModel.detail {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.error {
errorView(error)
} else if let detail = viewModel.detail {
ScrollView { ScrollView {
detailContent(detail) detailContent(detail)
} }
} else if let error = viewModel.error {
errorView(error)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
.navigationTitle(item.title) .navigationTitle(item.title)
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.task { .task(id: item.id) {
if viewModel.detail == nil { if viewModel.detail == nil {
await viewModel.loadDetail(path: item.detailURL) await viewModel.loadDetail(path: item.detailURL)
} }
} }
#if os(iOS) #if os(tvOS)
.fullScreenCover(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
)
}
}
#elseif os(iOS)
.sheet(isPresented: $showPlayer) { .sheet(isPresented: $showPlayer) {
if let episode = viewModel.currentEpisode { if let episode = viewModel.currentEpisode {
VideoPlayerView( VideoPlayerView(

View File

@@ -5,9 +5,15 @@ struct EpisodeListView: View {
let selectedIndex: Int let selectedIndex: Int
let onSelect: (Int) -> Void let onSelect: (Int) -> Void
#if os(tvOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8) GridItem(.adaptive(minimum: 70, maximum: 100), spacing: 8)
] ]
#endif
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {

View File

@@ -31,6 +31,7 @@ struct HomeView: View {
.navigationDestination(for: ContentItem.self) { item in .navigationDestination(for: ContentItem.self) { item in
DetailView(item: item) DetailView(item: item)
} }
#if !os(tvOS)
.searchable(text: $searchText, prompt: "搜索电影、电视剧...") .searchable(text: $searchText, prompt: "搜索电影、电视剧...")
.onSubmit(of: .search) { .onSubmit(of: .search) {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -52,6 +53,7 @@ struct HomeView: View {
await viewModel.loadHome() await viewModel.loadHome()
} }
} }
#endif
.task { .task {
await viewModel.loadHomeIfNeeded() await viewModel.loadHomeIfNeeded()
} }

View File

@@ -1,11 +1,12 @@
import SwiftUI import SwiftUI
enum AppTab: String, CaseIterable, Identifiable { enum AppTab: String, Identifiable {
case home case home
case movie case movie
case series case series
case variety case variety
case anime case anime
case search
case settings case settings
var id: String { rawValue } var id: String { rawValue }
@@ -17,6 +18,7 @@ enum AppTab: String, CaseIterable, Identifiable {
case .series: return "电视剧" case .series: return "电视剧"
case .variety: return "综艺" case .variety: return "综艺"
case .anime: return "动漫" case .anime: return "动漫"
case .search: return "搜索"
case .settings: return "设置" case .settings: return "设置"
} }
} }
@@ -28,6 +30,7 @@ enum AppTab: String, CaseIterable, Identifiable {
case .series: return "tv" case .series: return "tv"
case .variety: return "theatermasks" case .variety: return "theatermasks"
case .anime: return "sparkles" case .anime: return "sparkles"
case .search: return "magnifyingglass"
case .settings: return "gearshape" case .settings: return "gearshape"
} }
} }
@@ -41,6 +44,14 @@ enum AppTab: String, CaseIterable, Identifiable {
default: return nil default: return nil
} }
} }
static var visibleTabs: [AppTab] {
#if os(tvOS)
return [.home, .movie, .series, .variety, .anime, .search, .settings]
#else
return [.home, .movie, .series, .variety, .anime, .settings]
#endif
}
} }
struct AppNavigation: View { struct AppNavigation: View {
@@ -55,6 +66,8 @@ struct AppNavigation: View {
var body: some View { var body: some View {
#if os(macOS) #if os(macOS)
sidebarLayout sidebarLayout
#elseif os(tvOS)
tabLayout
#elseif os(visionOS) #elseif os(visionOS)
sidebarLayout sidebarLayout
#else #else
@@ -68,7 +81,7 @@ struct AppNavigation: View {
private var tabLayout: some View { private var tabLayout: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in ForEach(AppTab.visibleTabs) { tab in
NavigationStack { NavigationStack {
tabContent(for: tab) tabContent(for: tab)
} }
@@ -80,9 +93,10 @@ struct AppNavigation: View {
} }
} }
#if !os(tvOS)
private var sidebarLayout: some View { private var sidebarLayout: some View {
NavigationSplitView { NavigationSplitView {
List(AppTab.allCases, selection: $selectedTab) { tab in List(AppTab.visibleTabs, selection: $selectedTab) { tab in
Label(tab.title, systemImage: tab.icon) Label(tab.title, systemImage: tab.icon)
.tag(tab) .tag(tab)
} }
@@ -93,6 +107,7 @@ struct AppNavigation: View {
} }
} }
} }
#endif
@ViewBuilder @ViewBuilder
private func tabContent(for tab: AppTab) -> some View { private func tabContent(for tab: AppTab) -> some View {
@@ -107,6 +122,12 @@ struct AppNavigation: View {
BrowseView(category: .variety, viewModel: varietyVM) BrowseView(category: .variety, viewModel: varietyVM)
case .anime: case .anime:
BrowseView(category: .anime, viewModel: animeVM) BrowseView(category: .anime, viewModel: animeVM)
case .search:
#if os(tvOS)
TVSearchView()
#else
EmptyView()
#endif
case .settings: case .settings:
SettingsView() SettingsView()
} }

View File

@@ -31,7 +31,9 @@ struct NativePlayerView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> AVPlayerViewController { func makeUIViewController(context: Context) -> AVPlayerViewController {
let vc = AVPlayerViewController() let vc = AVPlayerViewController()
vc.player = player vc.player = player
#if !os(tvOS)
vc.allowsPictureInPicturePlayback = true vc.allowsPictureInPicturePlayback = true
#endif
return vc return vc
} }
@@ -52,18 +54,43 @@ struct VideoPlayerView: View {
@State private var viewModel = PlayerViewModel() @State private var viewModel = PlayerViewModel()
#if os(iOS) #if !os(macOS)
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
#endif #endif
var body: some View { var body: some View {
#if os(macOS) #if os(macOS)
macOSPlayer macOSPlayer
#elseif os(tvOS)
tvOSPlayer
#else #else
iOSPlayer iOSPlayer
#endif #endif
} }
#if os(tvOS)
private var tvOSPlayer: some View {
Group {
if let player = viewModel.player {
NativePlayerView(player: player)
.ignoresSafeArea()
} else {
ZStack {
Color.black.ignoresSafeArea()
ProgressView()
.tint(.white)
}
}
}
.onAppear {
viewModel.play(url: url, contentId: contentId, episodeId: episodeId)
}
.onDisappear {
viewModel.stop()
}
}
#endif
#if os(iOS) #if os(iOS)
private var iOSPlayer: some View { private var iOSPlayer: some View {
ZStack { ZStack {

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
#if !os(tvOS)
struct SearchView: View { struct SearchView: View {
@State private var viewModel = SearchViewModel() @State private var viewModel = SearchViewModel()
@@ -59,3 +60,4 @@ struct SearchView: View {
} }
} }
} }
#endif

View File

@@ -0,0 +1,74 @@
#if os(tvOS)
import SwiftUI
struct TVSearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
ScrollView {
VStack(spacing: 24) {
//
HStack {
TextField("搜索电影、电视剧...", text: $viewModel.query)
.textFieldStyle(.plain)
.onSubmit {
Task { await viewModel.search() }
}
}
.padding(.horizontal)
//
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)
Button("重试") {
Task { await viewModel.search() }
}
}
.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("未找到「\(viewModel.query)」的相关内容")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
} else if !viewModel.results.isEmpty {
ContentGridView(items: viewModel.results) {
Task { await viewModel.loadMore() }
}
.padding(.horizontal)
if viewModel.isLoadingMore {
ProgressView()
.padding()
}
} else {
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.tertiary)
Text("输入关键词搜索影片")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}
.padding(.vertical)
}
.navigationTitle("搜索")
.navigationDestination(for: ContentItem.self) { item in
DetailView(item: item)
}
}
}
#endif

Submodule LocalPackages/SwiftSoup updated: 8b6cf29eea...e98a6d63ce

23
Package.resolved Normal file
View File

@@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache.git",
"state" : {
"revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e",
"version" : "1.2.1"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
}
],
"version" : 2
}

View File

@@ -6,6 +6,7 @@ let package = Package(
platforms: [ platforms: [
.iOS(.v17), .iOS(.v17),
.macOS(.v14), .macOS(.v14),
.tvOS(.v17),
], ],
dependencies: [ dependencies: [
.package(path: "LocalPackages/SwiftSoup"), .package(path: "LocalPackages/SwiftSoup"),

View File

@@ -4,6 +4,7 @@ options:
deploymentTarget: deploymentTarget:
iOS: "17.0" iOS: "17.0"
macOS: "14.0" macOS: "14.0"
tvOS: "17.0"
xcodeVersion: "16.0" xcodeVersion: "16.0"
groupSortPosition: top groupSortPosition: top
createIntermediateGroups: true createIntermediateGroups: true
@@ -32,6 +33,26 @@ targets:
INFOPLIST_KEY_CFBundleDisplayName: DDYS INFOPLIST_KEY_CFBundleDisplayName: DDYS
CODE_SIGN_ENTITLEMENTS: DDYSClient/DDYSClient.entitlements CODE_SIGN_ENTITLEMENTS: DDYSClient/DDYSClient.entitlements
DDYSClient-tvOS:
type: application
platform: tvOS
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"
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleDisplayName: 低端影视
INFOPLIST_KEY_UILaunchScreen_Generation: YES
schemes: schemes:
DDYSClient: DDYSClient:
build: build:
@@ -39,3 +60,9 @@ schemes:
DDYSClient: all DDYSClient: all
run: run:
config: Debug config: Debug
DDYSClient-tvOS:
build:
targets:
DDYSClient-tvOS: all
run:
config: Debug